diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..941afc1 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "graehamwatts-skills", + "version": "1.0.0", + "description": "Graeham Watts personal skills vault: content creation engine, CMA generator, disclosure analyzer, offer analyzer, GHL CRM audit, content calendar, listing tools, and the Watts content+video pipeline. Publishing handled via the Composio workflow (see shared-references/publishing-via-composio.md)." +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2442f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Local skill outputs, caching, staging — never commit to GitHub +outputs/ +**/outputs/ +**/generated/ + +# Local credentials — NEVER commit +.claude-credentials/ +**/.claude-credentials/ +*.pat +*-pat.txt +github-pat.txt +github-token.txt +.github-token +*token*.txt +*.token +ghl-pit.txt +*-pit.txt +*.pit +*-credentials.txt +secrets.txt + +# OS / editor cruft +.DS_Store +Thumbs.db +*.swp +*~ +.vscode/ +.idea/ + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ + +# Node +node_modules/ + +# Session-local scripts and TODOs (never commit) +CLEANUP-*.bat +FINISH-*.bat +SYNC-*.bat +FINAL-*.bat +cleanup-and-commit.ps1 +NEXT-SESSION-TODO.md +test-persist.txt + +# Local credentials (never commit) +.env +*.env.local diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9468c6e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# READ THIS FIRST — Onboarding for Claude sessions touching this repo + +## Brand identity — the ONE rule that keeps getting violated + +**Single source of truth for Graeham's brand identity:** `skills/shared-references/identity.json` + +Read that file before writing ANY content that includes: +- DRE number (Graeham's individual salesperson DRE is `01466876`) +- Brokerage name +- Contact info (phone, email, website) +- Markets served + +**Do NOT hardcode brand details from memory or training.** California real estate marketing has multiple plausible-looking DRE numbers (brokerage DRE, salesperson DRE, archived numbers from old brokerages). It's tempting to type one in from prior context. **Don't.** Always read identity.json first. + +**Specifically prohibited:** the value `02015066` has been blocklisted ten separate times now (as of April 29, 2026). It is NOT Graeham's DRE, NOT Intero Real Estate's brokerage DRE (per Graeham's confirmation), and has no legitimate use anywhere in this repo or in outputs. If you find it in your context window, in a CMA template, in a contact strip, in a SKILL.md description, or anywhere else — **delete it. Do not propagate it.** Note that Cowork's cached skill descriptions may still show the wrong DRE; those are stale and should not be trusted over the actual SKILL.md files on disk. + +## Enforcement + +`scripts/verify_brand_identity.py` audits the entire repo against `identity.json`'s blocklist. It runs: + +1. As a local pre-push git hook (advisory — only runs on machines that have it installed). +2. Manually before every push. + +**Run the tripwire manually before pushing:** +```bash +python3 scripts/verify_brand_identity.py +``` + +If it fails, **fix the file paths it lists before pushing.** Do not bypass. + +## Repo structure (Option B architecture, 2026-04-29) + +This repo holds **source code only** — no outputs, no data bins. + +The repo root contains exactly these items: +- `skills/` — all 39 skills, each in its own folder. **Source of truth.** +- `scripts/` — repo-wide infrastructure scripts (currently just the brand-identity tripwire). +- `.claude-plugin/` — Cowork plugin manifest. +- `.nojekyll` — disables Jekyll on GitHub Pages. +- `index.html` + `assets/` — GitHub Pages landing page. +- `CLAUDE.md` (this file) — onboarding. +- `README.md` — public README. + +**Do NOT add output bins to this repo.** Generated content has its own home: + +| Output type | Where it goes | +|---|---| +| Published CMAs | `Graehamwatts/online-content/cmas/` | +| Published offer reports | `Graehamwatts/online-content/offers/` | +| Published disclosure reports | `Graehamwatts/online-content/disclosures/` | +| Published newsletters | `Graehamwatts/online-content/newsletters/` | +| Weekly production calendars | `Graehamwatts/online-content/dashboards/weekly-calendars/` | +| Per-topic single-topic dashboards | `Graehamwatts/online-content/dashboards/single-topic/` | +| Internal skill caching/staging | `/outputs/` (skill-local, gitignored) | + +The `online-content` repo is the **published content hub** — a separate repo because (1) it's a GitHub Pages site with public client-facing URLs, (2) outputs and source code shouldn't mix, and (3) it can be backed up/audited independently. + +> **Naming history:** This repo was renamed from `cma-reports` to `online-content` on 2026-05-01 to reflect that it holds ALL published content (CMAs, offers, disclosures, newsletters, dashboards) — not just CMAs. The old `cma-reports` repo has been retired; nothing migrated. + +## Content-creation primary skill + +The active content-engine skill is `skills/content-creation-engine/`. (The older `video-script-creation-engine` was retired during the 2026-04-29 reorganization.) When in doubt about which skill handles content/script generation, use `content-creation-engine`. + +## Tag of last known-good state + +`v2026.04.27-stable` — if anything regresses, compare against this tag. + + +## 2026-04-29 leak post-mortem (the 10th occurrence) + +**Where it leaked:** `Graehamwatts/cma-reports/Offer_828_Weeks_St.html` (in the now-retired `cma-reports` repo, since superseded by `online-content`) — a published GitHub Pages report for a real client offer comparison. The wrong DRE appeared on lines 523 and 753. + +**Root cause:** The Claude session that ran `offer-analyzer` on 2026-04-29 at 22:05 UTC had the wrong DRE (02015066) cached in its system prompt's `available_skills` list (specifically in the now-retired `video-script-creation-engine` description). Instead of reading the DRE from `identity.json` like this file instructs, that session typed the value from prior context. + +**Fix applied (2026-04-29):** +- Corrected the contaminated file in cma-reports +- Added a `BRAND IDENTITY HARD RULE` warning at the top of `cma-generator/SKILL.md` and `offer-analyzer/SKILL.md` that explicitly says "do NOT type from prior context" +- Retired `video-script-creation-engine` from GitHub (it's been merged into `content-creation-engine`); local Cowork sync should refresh the cache + +**Audit gap:** The tripwire (`scripts/verify_brand_identity.py`) only audits the skills repo. It does NOT currently audit `online-content` (the published-content sister repo, formerly `cma-reports`). A copy of the script should be added to `online-content` as well, OR this script extended to clone-and-audit `online-content` as part of its run. Open follow-up — increased priority since `online-content` will be the live target for every new CMA, offer, disclosure, newsletter, and dashboard going forward. diff --git a/README.md b/README.md index 60f7cfc..fdfeefa 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,5 @@ ## Usage This is an internal package and has no documentation. + +<\!-- sync-test: 15-04-2026-0421 --> diff --git a/assets/dashboard.css b/assets/dashboard.css new file mode 100644 index 0000000..db2c875 --- /dev/null +++ b/assets/dashboard.css @@ -0,0 +1,267 @@ +:root{ + --navy:#1B2A4A; + --navy-2:#2a3d6b; + --gold:#C5A258; + --gold-soft:rgba(197,162,88,0.12); + --green:#2e7d32; + --red:#c62828; + --amber:#e65100; + --blue:#1565C0; + --bg:#F7F5EF; + --card:#FFFFFF; + --border:rgba(27,42,74,0.10); + --border-strong:rgba(27,42,74,0.20); + --text:#1B2A4A; + --muted:#5a6478; + --muted-2:#8a92a3; + --radius:12px; + --radius-sm:8px; + --radius-pill:99px; + --shadow:0 2px 8px rgba(27,42,74,0.06); + --shadow-lg:0 6px 20px rgba(27,42,74,0.12); + --shadow-hover:0 4px 14px rgba(27,42,74,0.14); + --font-display:'Plus Jakarta Sans',system-ui,sans-serif; + --font-body:'DM Sans',system-ui,sans-serif; + --font-mono:ui-monospace,'SF Mono','Monaco',monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{scroll-behavior:smooth} +body{background:var(--bg);color:var(--text);font-family:var(--font-body);line-height:1.6;font-size:15px;-webkit-font-smoothing:antialiased} +a{color:inherit;text-decoration:none} +code{font-family:var(--font-mono);font-size:0.9em;background:var(--gold-soft);padding:1px 6px;border-radius:4px;color:var(--navy)} + +/* Top Nav (sticky) */ +.topnav{background:var(--navy);border-bottom:2px solid var(--gold);padding:12px 0;position:sticky;top:0;z-index:100;box-shadow:0 1px 3px rgba(0,0,0,0.05)} +.topnav-inner{max-width:1320px;margin:0 auto;padding:0 36px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap} +.topnav-brand{font-family:var(--font-display);font-size:13px;font-weight:800;letter-spacing:0.5px;color:#fff;display:flex;align-items:center;gap:10px} +.topnav-brand .dot{color:var(--gold);font-size:10px} +.topnav-links{display:flex;gap:14px;align-items:center} +.topnav-link{font-size:12px;font-weight:600;letter-spacing:0.3px;color:rgba(255,255,255,0.7);text-transform:uppercase;transition:color 0.15s} +.topnav-link:hover{color:#fff} +.topnav-link.active{color:var(--gold)} +.topnav-link.btn{background:var(--gold);color:var(--navy);padding:8px 16px;border-radius:var(--radius-pill);font-weight:700;letter-spacing:0.3px;transition:transform 0.15s,box-shadow 0.15s} +.topnav-link.btn:hover{transform:translateY(-1px);box-shadow:0 4px 10px rgba(197,162,88,0.4);color:var(--navy)} + +/* Page wrapper */ +.wrap{max-width:1320px;margin:0 auto;padding:40px 36px 60px} +.wrap.tight{max-width:1100px} + +/* Hero */ +.hero{background:linear-gradient(135deg,var(--navy) 0%,var(--navy-2) 60%,#3a5090 100%);color:#fff;padding:44px 44px 36px;border-radius:var(--radius);margin-bottom:32px;position:relative;overflow:hidden;box-shadow:var(--shadow-lg)} +.hero::after{content:'';position:absolute;top:-100px;right:-100px;width:340px;height:340px;border-radius:50%;background:rgba(197,162,88,0.08);pointer-events:none} +.hero-ey{font-family:var(--font-display);font-size:11px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:var(--gold);margin-bottom:10px;position:relative} +.hero h1{font-family:var(--font-display);font-size:36px;font-weight:800;margin-bottom:10px;line-height:1.15;letter-spacing:-0.5px;position:relative} +.hero .hsub{font-size:15px;color:rgba(255,255,255,0.78);max-width:680px;line-height:1.6;position:relative} +.hero-meta{display:flex;gap:8px;margin-top:22px;flex-wrap:wrap;position:relative} +.hm-pill{background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.18);padding:6px 14px;border-radius:var(--radius-pill);font-size:11px;font-weight:600;letter-spacing:0.3px} +.hm-pill.gold{background:var(--gold);color:var(--navy);border-color:var(--gold);font-weight:700} + +/* Section heading */ +.sh{font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--navy);margin:44px 0 6px;letter-spacing:-0.2px;display:flex;align-items:baseline;gap:10px} +.sh:first-child{margin-top:0} +.sh .sh-count{font-size:14px;font-weight:600;color:var(--muted-2);letter-spacing:0} +.sh-sub{font-size:13px;color:var(--muted);margin-bottom:20px;max-width:800px;line-height:1.6} + +/* Peter Read First card */ +.peter-card{background:var(--navy);color:#fff;padding:22px 26px;border-radius:var(--radius);margin-bottom:30px;box-shadow:var(--shadow);border-left:4px solid var(--gold)} +.peter-card details summary{list-style:none;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:12px} +.peter-card details summary::-webkit-details-marker{display:none} +.peter-card details summary::after{content:'+';font-size:22px;color:var(--gold);font-weight:700;line-height:1;transition:transform 0.2s} +.peter-card details[open] summary::after{content:'−'} +.peter-card .peter-head{display:flex;align-items:center;gap:12px} +.peter-card .peter-tag{background:var(--gold);color:var(--navy);padding:3px 10px;border-radius:var(--radius-pill);font-size:10px;font-weight:800;letter-spacing:0.6px;text-transform:uppercase} +.peter-card .peter-title{font-family:var(--font-display);font-size:16px;font-weight:700} +.peter-card .peter-body{margin-top:18px;padding-top:18px;border-top:1px solid rgba(255,255,255,0.15);font-size:13px;line-height:1.7;color:rgba(255,255,255,0.85)} +.peter-card .peter-body p{margin-bottom:10px} +.peter-card .peter-body strong{color:#fff} + +/* Day Tiles Grid */ +.day-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;margin-bottom:44px} +@media(max-width:1000px){.day-grid{grid-template-columns:repeat(3,1fr)}} +@media(max-width:640px){.day-grid{grid-template-columns:repeat(2,1fr)}} +@media(max-width:430px){.day-grid{grid-template-columns:1fr}} + +.day-tile{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px 18px;text-decoration:none;color:inherit;display:flex;flex-direction:column;min-height:200px;box-shadow:var(--shadow);transition:transform 0.2s,box-shadow 0.2s,border-color 0.2s;position:relative;overflow:hidden} +.day-tile:hover{transform:translateY(-3px);box-shadow:var(--shadow-hover);border-color:var(--gold)} +.day-tile.breaking{border:2px solid var(--red)} +.day-tile .dt-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px} +.day-tile .dt-day{font-family:var(--font-display);font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted)} +.day-tile .dt-date{font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--navy);line-height:1;margin-top:2px} +.day-tile .dt-status{font-size:9px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;padding:3px 8px;border-radius:4px} +.day-tile .dt-status.shipped{background:rgba(46,125,50,0.12);color:var(--green)} +.day-tile .dt-status.scheduled{background:rgba(230,81,0,0.12);color:var(--amber)} +.day-tile .dt-status.draft{background:rgba(90,100,120,0.12);color:var(--muted)} +.day-tile .dt-status.breaking{background:rgba(198,40,40,0.12);color:var(--red)} +.day-tile .dt-title{font-family:var(--font-display);font-size:14px;font-weight:700;color:var(--navy);line-height:1.35;margin-bottom:10px;flex:1} +.day-tile .dt-meta{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px;font-size:10px;color:var(--muted)} +.day-tile .dt-badge{padding:2px 7px;border-radius:4px;background:var(--gold-soft);color:var(--navy);font-weight:700;letter-spacing:0.3px;text-transform:uppercase} +.day-tile .dt-badge.score{background:rgba(27,42,74,0.06);color:var(--navy)} +.day-tile .dt-footer{display:flex;justify-content:space-between;align-items:center;padding-top:12px;border-top:1px dashed var(--border);font-size:11px} +.day-tile .dt-why{color:var(--muted);font-style:italic;flex:1;margin-right:10px;line-height:1.4} +.day-tile .dt-arrow{color:var(--gold);font-weight:800;font-size:16px} + +/* Generic card */ +.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:22px 24px;box-shadow:var(--shadow);margin-bottom:16px} +.card h3{font-family:var(--font-display);font-size:17px;font-weight:800;color:var(--navy);margin-bottom:8px} + +/* Collapsible accordion (matches existing v5-research style) */ +.acc{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:14px;box-shadow:var(--shadow);overflow:hidden} +.acc details summary{list-style:none;cursor:pointer;padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:14px;font-family:var(--font-display);font-size:15px;font-weight:700;color:var(--navy)} +.acc details summary::-webkit-details-marker{display:none} +.acc details summary::after{content:'+';font-size:22px;color:var(--gold);font-weight:700;line-height:1;min-width:20px;text-align:right} +.acc details[open] summary::after{content:'−'} +.acc .acc-sub{font-family:var(--font-body);font-size:12px;font-weight:500;color:var(--muted);letter-spacing:0;margin-left:4px} +.acc details[open] summary{border-bottom:1px solid rgba(197,162,88,0.25)} +.acc .acc-body{padding:20px 24px 24px} + +/* Tables */ +table.t{width:100%;border-collapse:collapse;font-size:13px;background:transparent} +table.t thead th{text-align:left;padding:10px 12px;font-size:10px;font-weight:800;color:var(--muted);letter-spacing:0.8px;text-transform:uppercase;border-bottom:1px solid var(--border)} +table.t tbody td{padding:10px 12px;border-bottom:1px solid rgba(27,42,74,0.05);vertical-align:top;line-height:1.5} +table.t tbody td.num{font-weight:700;color:var(--navy);white-space:nowrap;width:80px;text-align:right;font-family:var(--font-mono)} +table.t tbody tr.total td{background:var(--gold-soft);border-top:2px solid var(--border);font-weight:700} + +/* Scoring Architecture two-table grid */ +.sa-wrap{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:4px 0} +@media(max-width:900px){.sa-wrap{grid-template-columns:1fr}} +.sa-col{background:#FCFAF4;border:1px solid var(--border);border-radius:var(--radius-sm);padding:18px} +.sa-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-family:var(--font-display);font-size:14px;font-weight:800;color:var(--navy)} +.sa-head .sa-total{color:var(--gold);font-size:18px;font-weight:800} +.sa-owner{font-size:10px;color:var(--muted);margin-bottom:14px;font-family:var(--font-mono)} +.sa-ok{color:var(--green);font-weight:700} +.sa-warn{color:var(--amber);font-weight:700} +.sa-bad{color:var(--red);font-weight:700} + +/* Priority Axes */ +.pa{display:flex;flex-direction:column;gap:10px;margin-top:16px;padding:18px;background:#FCFAF4;border:1px solid var(--border);border-radius:var(--radius-sm)} +.pa-h{font-family:var(--font-display);font-size:11px;font-weight:800;color:var(--muted);letter-spacing:1px;text-transform:uppercase} +.pa-row{display:grid;grid-template-columns:100px 1fr 60px;align-items:center;gap:12px;font-size:13px} +.pa-label{color:var(--muted);font-weight:700} +.pa-track{background:rgba(27,42,74,0.08);height:9px;border-radius:5px;overflow:hidden} +.pa-fill{height:100%;border-radius:5px;transition:width 0.3s} +.pa-val{text-align:right;font-weight:700;color:var(--navy);font-family:var(--font-mono);font-size:12px} + +/* Buttons */ +.btn{display:inline-flex;align-items:center;gap:8px;background:var(--gold);color:var(--navy);padding:13px 24px;border-radius:var(--radius-pill);font-family:var(--font-display);font-size:14px;font-weight:700;border:none;cursor:pointer;text-decoration:none;transition:transform 0.15s,box-shadow 0.15s;letter-spacing:0.2px} +.btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(197,162,88,0.35)} +.btn.navy{background:var(--navy);color:#fff} +.btn.navy:hover{box-shadow:0 4px 12px rgba(27,42,74,0.3)} +.btn.outline{background:transparent;color:var(--navy);border:2px solid var(--navy)} +.btn.outline:hover{background:var(--navy);color:#fff;box-shadow:none} +.btn.sm{padding:8px 16px;font-size:12px} +.btn .arrow{font-size:16px} + +/* Breadcrumb (day views) */ +.crumb{font-size:13px;color:var(--muted);margin-bottom:20px;display:flex;align-items:center;gap:8px;font-weight:600} +.crumb a{color:var(--muted);transition:color 0.15s} +.crumb a:hover{color:var(--navy)} +.crumb .sep{color:var(--muted-2)} +.crumb .current{color:var(--navy)} + +/* Footer */ +.footer{margin-top:60px;padding:24px 0 10px;border-top:1px solid var(--border);font-size:12px;color:var(--muted);line-height:1.7} +.footer a{color:var(--navy);font-weight:600} +.footer a:hover{text-decoration:underline} +.footer-links{display:flex;flex-wrap:wrap;gap:14px;margin-top:8px;margin-bottom:16px} + +/* Goal Mix Table small variant */ +.mix-tbl-sm{font-size:13px} +.mix-tbl-sm td{padding:8px 12px !important} + +/* Placeholder callout for stubbed day views */ +.placeholder{background:rgba(197,162,88,0.06);border:1px dashed var(--gold);border-radius:var(--radius);padding:20px 24px;text-align:center;color:var(--muted);font-size:13px;line-height:1.7} +.placeholder strong{color:var(--navy);display:block;margin-bottom:4px;font-family:var(--font-display);font-size:15px} + + +/* Content Creation Section */ +.cc-overview{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:24px} +.cc-stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px 16px;text-align:center;box-shadow:var(--shadow)} +.cc-stat .cc-num{font-family:var(--font-display);font-size:26px;font-weight:800;color:var(--navy);line-height:1} +.cc-stat .cc-lbl{font-size:11px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;color:var(--muted);margin-top:4px} +.cc-stat.gold .cc-num{color:var(--gold)} + +.format-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px;margin-bottom:32px} +.format-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:20px 22px;box-shadow:var(--shadow);display:flex;flex-direction:column;transition:border-color 0.15s,box-shadow 0.15s} +.format-card:hover{border-color:var(--gold)} +.format-card .fc-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:10px} +.format-card .fc-tag{background:var(--navy);color:#fff;padding:4px 10px;border-radius:4px;font-size:10px;font-weight:800;letter-spacing:0.6px;text-transform:uppercase} +.format-card .fc-tag.yt{background:#c62828} +.format-card .fc-tag.ig{background:#c13584} +.format-card .fc-tag.tt{background:#010101} +.format-card .fc-tag.blog{background:#2e7d32} +.format-card .fc-tag.fb{background:#1877f2} +.format-card .fc-tag.li{background:#0a66c2} +.format-card .fc-tag.gmb{background:#ea4335} +.format-card .fc-tag.nl{background:var(--gold);color:var(--navy)} +.format-card .fc-tag.ad{background:#ff6f00} +.format-card .fc-meta{font-size:11px;color:var(--muted);font-family:var(--font-mono);font-weight:600} +.format-card h3{font-family:var(--font-display);font-size:16px;font-weight:700;color:var(--navy);margin-bottom:6px;line-height:1.35} +.format-card .fc-desc{font-size:13px;color:var(--muted);line-height:1.55;margin-bottom:14px;flex:1} +.format-card .fc-status{font-size:11px;font-weight:700;letter-spacing:0.4px;text-transform:uppercase;padding:3px 8px;border-radius:4px;display:inline-block;margin-bottom:10px} +.format-card .fc-status.ready{background:rgba(46,125,50,0.12);color:var(--green)} +.format-card .fc-status.pending{background:rgba(230,81,0,0.1);color:var(--amber)} +.format-card details summary{list-style:none;cursor:pointer;background:var(--gold);color:var(--navy);padding:10px 16px;border-radius:var(--radius-pill);font-family:var(--font-display);font-size:13px;font-weight:700;display:inline-flex;align-items:center;gap:6px;text-align:center;justify-content:center;transition:transform 0.15s,box-shadow 0.15s;align-self:flex-start} +.format-card details summary::-webkit-details-marker{display:none} +.format-card details summary:hover{transform:translateY(-1px);box-shadow:0 3px 8px rgba(197,162,88,0.35)} +.format-card details summary::after{content:'↓';margin-left:2px;font-weight:700} +.format-card details[open] summary::after{content:'↑'} +.format-card details[open] summary{background:var(--navy);color:#fff} +.format-card .fc-content{margin-top:14px;padding:14px;background:#FCFAF4;border-radius:var(--radius-sm);border:1px solid var(--border);font-family:var(--font-mono);font-size:12px;line-height:1.7;white-space:pre-wrap;color:var(--text)} +.format-card .fc-copy{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap} +.format-card .fc-note{font-size:11px;color:var(--muted);margin-top:10px;font-style:italic;padding-top:10px;border-top:1px dashed var(--border)} + + +/* Modal overlay (for Content Creation) */ +.modal-backdrop{position:fixed;inset:0;background:rgba(27,42,74,0.65);backdrop-filter:blur(4px);z-index:9000;display:none;align-items:flex-start;justify-content:center;padding:40px 24px;overflow-y:auto} +.modal-backdrop.open{display:flex} +.modal{background:var(--card);border-radius:var(--radius);max-width:860px;width:100%;box-shadow:0 20px 60px rgba(0,0,0,0.3);position:relative;margin:0 auto} +.modal-head{padding:22px 28px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:flex-start;gap:16px} +.modal-head .modal-tag{background:var(--navy);color:#fff;padding:4px 10px;border-radius:4px;font-size:10px;font-weight:800;letter-spacing:0.6px;text-transform:uppercase;display:inline-block;margin-bottom:6px} +.modal-head h3{font-family:var(--font-display);font-size:18px;font-weight:800;color:var(--navy);line-height:1.3} +.modal-head .modal-meta{font-size:12px;color:var(--muted);font-family:var(--font-mono);margin-top:4px} +.modal-close{background:transparent;border:none;font-size:26px;font-weight:700;color:var(--muted);cursor:pointer;line-height:1;padding:0;min-width:32px;transition:color 0.15s} +.modal-close:hover{color:var(--red)} +.modal-body{padding:24px 28px;max-height:68vh;overflow-y:auto} +.modal-body .m-content{font-family:var(--font-mono);font-size:13px;line-height:1.8;white-space:pre-wrap;color:var(--text);background:#FCFAF4;padding:20px;border-radius:var(--radius-sm);border:1px solid var(--border)} +.modal-body .m-help{font-size:13px;color:var(--muted);margin-bottom:16px;line-height:1.6} +.modal-actions{padding:16px 28px;border-top:1px solid var(--border);display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap} + +/* Scoring explanation box */ +.sa-howto{background:rgba(27,42,74,0.04);border-left:3px solid var(--navy);padding:12px 16px;font-size:12px;line-height:1.7;color:var(--muted);margin-bottom:14px;border-radius:0 var(--radius-sm) var(--radius-sm) 0} +.sa-howto strong{color:var(--navy);font-family:var(--font-display);display:block;margin-bottom:6px;font-size:11px;letter-spacing:0.5px;text-transform:uppercase} + +/* Research card as button */ +.research-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;margin-top:18px} +.research-card-btn{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;box-shadow:var(--shadow);text-decoration:none;color:inherit;display:flex;flex-direction:column;transition:transform 0.15s,box-shadow 0.15s,border-color 0.15s;cursor:pointer} +.research-card-btn:hover{transform:translateY(-2px);box-shadow:var(--shadow-hover);border-color:var(--gold)} +.research-card-btn .rc-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px} +.research-card-btn h4{font-family:var(--font-display);font-size:14px;font-weight:700;color:var(--navy)} +.research-card-btn .rc-status{font-size:9px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;padding:2px 7px;border-radius:4px;white-space:nowrap} +.research-card-btn .rc-status.live{background:rgba(46,125,50,0.12);color:var(--green)} +.research-card-btn .rc-status.demo{background:rgba(230,81,0,0.12);color:var(--amber)} +.research-card-btn .rc-status.stub{background:rgba(90,100,120,0.12);color:var(--muted)} +.research-card-btn .rc-desc{font-size:12px;color:var(--muted);line-height:1.55;margin-bottom:10px;flex:1} +.research-card-btn .rc-meta{font-size:11px;color:var(--muted-2);display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px dashed var(--border)} +.research-card-btn .rc-arrow{color:var(--gold);font-weight:800} + +/* Lead Capture pipeline callout */ +.lead-pipeline{background:linear-gradient(135deg,var(--navy) 0%,var(--navy-2) 100%);color:#fff;padding:22px 26px;border-radius:var(--radius);margin-top:18px;box-shadow:var(--shadow)} +.lead-pipeline h3{font-family:var(--font-display);font-size:15px;font-weight:800;margin-bottom:8px;color:#fff} +.lead-pipeline p{font-size:13px;line-height:1.7;color:rgba(255,255,255,0.8)} +.lead-pipeline code{background:rgba(255,255,255,0.12);color:var(--gold);padding:1px 8px;border-radius:4px} + +/* Demo data warning */ +.demo-warn{display:inline-flex;align-items:center;gap:6px;background:rgba(230,81,0,0.1);border:1px solid var(--amber);color:var(--amber);padding:3px 10px;border-radius:4px;font-size:10px;font-weight:700;letter-spacing:0.4px;text-transform:uppercase;margin-left:8px} + + + +/* Format card buttons: primary + prompt (outline) + production (purple) */ +.fc-buttons{display:flex;gap:8px;flex-wrap:wrap;align-items:center} +.fc-buttons .btn{padding:9px 16px;font-size:12px} +.btn.gold-outline{background:transparent;color:var(--gold);border:1.5px solid var(--gold)} +.btn.gold-outline:hover{background:var(--gold);color:var(--navy);box-shadow:0 3px 8px rgba(197,162,88,0.3)} +.btn.purple{background:#5b2c8b;color:#fff;border:1.5px solid #5b2c8b} +.btn.purple:hover{background:#4a2170;box-shadow:0 3px 8px rgba(91,44,139,0.35)} + +/* Success flash on copy buttons */ +.btn.copied{background:var(--green) !important;color:#fff !important;border-color:var(--green) !important} diff --git a/docs/architecture-and-build-guide.md b/docs/architecture-and-build-guide.md new file mode 100644 index 0000000..dc79ddc --- /dev/null +++ b/docs/architecture-and-build-guide.md @@ -0,0 +1,597 @@ +# Architecture & Build Guide + +> **Read this first.** If you're contributing to or rebuilding any part of Graeham Watts's skills repo, this document describes what's built, why it's built that way, and what's open work. Last updated April 2026. + +This guide is written for a developer joining the project (Mehmood — building Graeham's systems alongside Uzair, Khawaja, Wattson). It assumes you can read code, but doesn't assume you've worked with Cowork or Claude Skills before. + +--- + +## Table of Contents + +1. [The Project: PropCast](#the-project-propcast) +2. [Repo Structure](#repo-structure) +3. [Core Architectural Decisions](#core-architectural-decisions) +4. [Data Flow Diagrams](#data-flow-diagrams) +5. [Skills Inventory](#skills-inventory) +6. [How Cowork Skills Work (Mechanics)](#how-cowork-skills-work-mechanics) +7. [Identity & Brand Hard Rules](#identity--brand-hard-rules) +8. [Integrations](#integrations) +9. [Pantana Reference (Category Context)](#pantana-reference-category-context) +10. [Open Work / Known Issues](#open-work--known-issues) +11. [Build Priorities](#build-priorities) +12. [How to Develop on This Repo](#how-to-develop-on-this-repo) + +--- + +## The Project: PropCast + +PropCast is a unified **content + transaction operating system** for real estate agents, built first for Graeham Watts (REALTOR, Intero Real Estate, Bay Area / East Palo Alto) and structured to be productized for other agents later. + +PropCast covers two surfaces: + +**Content side** (week-over-week, evergreen) +- Research signals across multiple data sources +- Topic ideation + scoring +- Multi-format content generation (video scripts, blog posts, social, email) +- Distribution (HeyGen avatar video, ElevenLabs voice, GoHighLevel CRM keyword capture, GitHub Pages publishing) + +**Transaction side** (deal-by-deal) +- CMA generation (3-strategy pricing, branded report, GitHub Pages publishing) +- Offer analysis + comparison (multi-offer net sheets) +- Disclosure / inspection report analysis +- Listing remarks (MLS) + photo captions +- Price reduction conversations (data-backed angle for the seller convo) + +Both sides are powered by **skills** running in **Cowork** (Anthropic's desktop product), backed by external integrations (Apify, Windsor MCP, GoHighLevel, HeyGen, ElevenLabs, GitHub). The skills repo is the codebase. + +--- + +## Repo Structure + +``` +Graehamwatts/skills/ (this repo) +├── .claude-plugin/ +│ └── plugin.json (Cowork plugin manifest) +├── .git/, .nojekyll, index.html, assets/ (GitHub Pages landing infra) +├── CLAUDE.md (root onboarding doc) +├── README.md (public README) +├── docs/ +│ └── architecture-and-build-guide.md (THIS FILE) +├── scripts/ +│ └── verify_brand_identity.py (DRE-leak tripwire) +└── skills/ (all 39+ skills) + ├── shared-references/ + │ ├── identity.json (BRAND IDENTITY SSOT) + │ ├── integrations.md (canonical integration matrix) + │ └── data-contracts.md (cross-skill JSON contracts) + ├── content-creation-engine/ (main content engine) + ├── content-calendar/ (weekly planning) + ├── bofu-query-generator/ (standalone BOFU) + ├── bofu-intent-scorer/ (standalone BOFU scorer) + ├── cma-generator/ + ├── offer-analyzer/ + ├── disclosure-analyzer/ + ├── listing-remarks-writer/ + ├── listing-photo-captioner/ + ├── price-reduction-angle-generator/ + ├── youtube-scraper/ + ├── ... (other skills — see Skills Inventory below) + └── ... +``` + +**Sister repo: `Graehamwatts/online-content`** — published content hub (separate repo because it's a GitHub Pages site with public client-facing URLs; outputs and source code shouldn't mix). Renamed from `cma-reports` on 2026-05-01 to reflect that it holds ALL published content types, not just CMAs. The old `cma-reports` repo was retired with no migration — its content was disposable. + +| Output type | Where it goes | +|---|---| +| Published CMAs | `Graehamwatts/online-content/cmas/` | +| Published offer reports | `Graehamwatts/online-content/offers/` | +| Published disclosure reports | `Graehamwatts/online-content/disclosures/` | +| Published newsletters | `Graehamwatts/online-content/newsletters/` | +| Weekly production calendars | `Graehamwatts/online-content/dashboards/weekly-calendars/` | +| Per-topic single-topic dashboards | `Graehamwatts/online-content/dashboards/single-topic/` | + +--- + +## Core Architectural Decisions + +### 1. GitHub Is the Source of Truth + +- Skills repo: `https://github.com/Graehamwatts/skills` +- Working copy: clone via GitHub Desktop to `~/Documents/GitHub/skills` +- Cowork local plugin folder (`%APPDATA%/Claude/local-agent-mode-sessions/skills-plugin/`) is **downstream**. It syncs FROM GitHub. Edits made there don't persist. +- All architectural changes go: edit → commit → push → Cowork picks up updated skills on next session + +If the local Cowork plugin shows skills that don't match GitHub, the local cache is stale. The remedy is forcing a Cowork sync (close + reopen Cowork session typically refreshes). + +### 2. Engine + Standalone Pattern + +Skills are organized in two tiers: + +**Engines** — orchestrate multi-phase workflows. Examples: `content-creation-engine`, `content-calendar` (which absorbed `social-media-analyzer` in May 2026). + +**Standalones** — single-purpose, can be invoked directly OR referenced by an engine. Examples: `bofu-query-generator`, `listing-remarks-writer`, `cma-generator`. + +Engines reference standalones via **sibling-path imports** in their SKILL.md (e.g., engine's Phase 1 says "Read `../bofu-query-generator/SKILL.md`"). This is DRY: + +- Standalones can be invoked alone — user says "generate BOFU queries for Redwood City" → standalone fires +- Engines pull standalones into pipelines — user says "build content package on EPA homicide-free story" → engine fires, internally reads standalone's instructions +- One source of truth per skill — edits propagate + +**Engine-internal sub-modules** (not standalone-useful) live INSIDE the engine folder: +- `content-creation-engine/references/phases/` — the 6 internal phases (source-ingestion, content-ideation-engine, funnel-tagger, script-writer) +- `content-creation-engine/modules/` — sub-modules (newsletter, market-update-narrative) + +### 3. Two-Score Model (Content Architecture) + +The content system has **two distinct scores** answering two distinct questions. They are NOT interchangeable and must NEVER be merged: + +| Score | Owner | Scale | Answers | When applied | +|---|---|---|---|---| +| **Opportunity Score** | `content-calendar` | 25 pts (5 criteria × 5) | "Should we cover this topic THIS WEEK vs other candidates?" | Once per week, across 12-15 candidates. Top 4-5 by score → weekly calendar. | +| **Intent Score** | `bofu-intent-scorer` (standalone) | 25 pts (5 criteria × 5) + freshness ±5 | "What's the BOFU intent of this topic (DECISION / CONSIDERATION / AWARENESS)?" | Once per topic, AFTER opportunity selection. Used for funnel-mix and CTA decisions. | + +Both scores are rendered side-by-side on the per-topic dashboard so the distinction stays visible. See `content-creation-engine/SKILL.md` → Scoring Architecture section for full model. + +### 4. Audience-Targeted Button Pattern (Dashboards) + +Per-topic dashboards have buttons targeted at specific team members: + +- **Blog producer** (publishing team — posts content to platforms): gets the **Copy Content** button (gold solid). The blog producer never needs to regenerate; he posts what's already produced. +- **Peter** (video production): gets the **Copy Script Prompt** button (gold outline) and **Copy Production Prompt** button (purple). Peter regenerates as needed for his AI tools. + +Non-video formats (Blog, Email, GMB, Facebook, IG Carousel) have 2 buttons (blog only). Video formats (YT Long Pt1+Pt2, YT Short, IG Reel #1, IG Reel #2, TikTok) have 3 buttons (blog + video). + +Button colors carry semantic meaning: +- **Gold solid** = Blog Track's primary action (post-ready content) +- **Gold outline** = secondary regeneration / script-side prompt +- **Purple solid** = Peter's production-side prompt +- **Navy** = UI chrome (toggles, expanders, navigation) + +See `content-creation-engine/references/single-topic-dashboard-rules.md` Rule 3 for full spec. + +### 5. Weekly Output: HTML Calendar + Three-Tier Email + +content-calendar produces TWO weekly outputs: + +1. **HTML Production Calendar** (hosted on GitHub Pages) — the full multi-tab dashboard for Jason (video editor) and Peter (production). Three tabs: Analytics, Production Map, Copy Bank. + +2. **Three-Tier Email for Blog Track** (sent Monday + daily) — Blog Track's quick-decision surface. Topics ranked into Top tier (Score 22-25, "must_create"), Next tier (17-21, "strong"), Third tier (12-16, "consider"). Each email link deep-links into the dashboard where the Copy buttons live (email clients strip JS — buttons can't work IN the email). + +See `content-calendar/SKILL.md` → "Weekly Email Format (for the Blog Producer)" section. + +### 6. YouTube Source Ingestion: Two Modes + +Phase 0 of content-creation-engine has two distinct modes: + +- **Mode A — Single-URL Transcription** — user pastes a video URL → `youtube_transcriber.py` runs (caption pull → Whisper fallback) +- **Mode B — Channel Monitoring** — user pastes a channel URL OR a scheduled task fires → `youtube-scraper` standalone scans for new uploads in the time window, delegates transcripts to youtube_transcriber.py for each + +The orchestrator picks the right mode based on what the user provided. Don't fire Mode B for a single URL or vice versa. + +--- + +## Data Flow Diagrams + +### Weekly Planning Flow + +``` +[Data Sources — see integrations.md] + ├── Windsor MCP (Instagram, Facebook, YouTube, GSC) + ├── Apify Reddit scraper (trudax/reddit-scraper-lite) + ├── MLSListings (Chrome) + ├── Google Trends (Chrome) + ├── Local news (web search) + EPA gov (Chrome) + └── Apify competitor scrapers + │ + ▼ + 12-15 topic candidates extracted + │ + ▼ + Opportunity Score applied (25 pts) + • Performance Signal (5) + • Search Demand (5) + • Audience Intent (5) + • Competitive Gap (5) + • Timeliness (5) + │ + ▼ + Top 4-5 → weekly calendar + Tier breakdown: + • Top tier (22-25): "must_create" + • Next tier (17-21): "strong" + • Third tier (12-16): "consider" + │ + ▼ + Two outputs: + ├── HTML Production Calendar (online-content/dashboards/weekly-calendars/{date}-production-calendar-v6.html) + └── Blog Track's three-tier email (outputs/emails/weekly-{date}-blog.html) + │ + ▼ + For each selected topic → handoff to content-creation-engine (per-topic flow) +``` + +### Per-Topic Production Flow + +``` +Topic arrives at content-creation-engine +(from content-calendar weekly plan OR direct user ask) + │ + ▼ +Phase 0a — Clarifier Check (only if ambiguous) + │ + ▼ +Phase 0 — Source Ingestion (only if user provided YouTube URL or channel) + • Mode A: youtube_transcriber.py + • Mode B: youtube-scraper → youtube_transcriber.py + │ + ▼ +Phase R — Per-Topic Research + • Topic-matched MLS stats (MLSListings via Chrome) + • Topic-matched GSC queries (Windsor + Direct API parallel-pull) + • Topic-matched local news (web search + EPA gov) + • Topic-matched social signal (Windsor) + • Topic-matched competitor coverage (Apify) + • Topic-matched Reddit signal (Apify Reddit scraper output) + │ + ▼ +Phase G — Generate Content (with topic-type routing) + • Market update topics → modules/market-update-narrative/ → Phase 5 + • Listing spotlight → ../listing-remarks-writer/ + ../listing-photo-captioner/ → Phase 5 + • Price reduction → ../price-reduction-angle-generator/ (PRIVATE — no public output) + • Education / how-to → Phase 5 directly + │ + ▼ +Phase 1 — BOFU Query Generator (standalone) — only for ideation-driven runs + │ + ▼ +Phase 2 — Content Ideation (Reddit/Apify scrape) + │ + ▼ +Phase 3 — BOFU Intent Scorer (standalone) — DECISION/CONSIDERATION/AWARENESS classification + │ + ▼ +Phase 4 — Funnel Tagger (TOFU/MOFU/BOFU mix) + │ + ▼ +Phase 5 — Script Writer + • References: content-pillars, platform-specs, voice-and-style, seo-keywords, + aeo-geo-requirements, lead-capture-keywords, elevenlabs-audio-tags + • Conditional (when format = blog): schema-markup-templates, + rss-internal-linking, youtube-embed-patterns + │ + ▼ +Outputs: + • outputs/content-package-{ts}.md (full package — scripts, captions, etc.) + • outputs/content-package-{ts}.ssml.txt (raw SSML for renderer) + • online-content/dashboards/single-topic/{date}-{slug}-production.html (per-topic dashboard) + │ + ▼ +Phase A — Review & Approve (user) + │ + ▼ +Phase D — Distribute + • Newsletter → Gmail draft (via Gmail MCP) + • Blog → ready for CMS publish + • Social → platform-specific posts queued + • Video → handoff to heygen-elevenlabs-renderer: + full_render.py → ElevenLabs (voice) → HeyGen (avatar) → MP4 +``` + +### Transaction-Side Flow (CMA Example) + +``` +User uploads MLS data + property details + │ + ▼ +cma-generator + • Reads identity.json (DRE, brokerage, contact) + • References branding.md (colors, fonts, logo) + • References charts.md (matplotlib styling) + │ + ▼ +Three-strategy pricing analysis + 1. Aspirational + 2. Market-Aligned + 3. Move-It + │ + ▼ +Three output formats: + • Interactive HTML Report (Chart.js, sticky nav, animated counters) + • Email-Safe HTML (table-based, inline styles) + • PDF (print-optimized HTML → WeasyPrint/xhtml2pdf) + │ + ▼ +Publish to GitHub Pages + online-content/cmas/CMA_{address}.html + │ + ▼ +Live URL: https://graehamwatts.github.io/online-content/cmas/CMA_{address}.html +``` + +--- + +## Skills Inventory + +The repo has 39+ skills as of April 2026. Categorized: + +### Engines (orchestrators) +- `content-creation-engine` — main content production pipeline, per-topic +- `content-calendar` — weekly planning + performance analytics (absorbed `social-media-analyzer` May 2026) + +### Standalone Content Skills +- `bofu-query-generator` — 230+ localized BOFU search queries +- `bofu-intent-scorer` — Intent Score (DECISION/CONSIDERATION/AWARENESS) +- `cinematic-hooks` — pattern-interrupt video prompts +- `vaibhav-template` — talking-head Vaibhav-style aesthetic +- `video-prompt-builder` — Seedance shot lists +- `youtube-scraper` — channel monitoring (different from URL transcription) + +### Transaction-Side Skills +- `cma-generator` — branded CMA reports (PDF + HTML + email) +- `offer-analyzer` — multi-offer comparison + seller net sheets +- `disclosure-analyzer` — TDS/SPQ/inspection report analysis +- `listing-remarks-writer` — MLS public remarks (walkthrough + condition-aware) +- `listing-photo-captioner` — per-photo MLS captions +- `price-reduction-angle-generator` — data-backed seller convo angle + +### Video Production Skills +- `heygen-video` — single-call HeyGen avatar video +- `heygen-elevenlabs-renderer` — ElevenLabs → HeyGen pipeline (voice + avatar) +- `video-creator` — Python + ffmpeg slideshow videos +- `remotion-video` — React-based programmatic video +- `higgsfield-video` — Higgsfield AI b-roll + +### Communication Skills +- `html-email` — branded HTML email generation + GitHub Pages hosting + +### CRM / Operations +- `ghl-crm-audit` — GoHighLevel audit + N8N workflow building + +### Document Processing +- `docx`, `pdf`, `xlsx`, `pptx` — Office document creation/editing + +### Infrastructure +- `github-skill-sync` — automated repo backup +- `skill-creator` — skill scaffolding + evaluation +- `schedule` — scheduled tasks +- `consolidate-memory` — memory file maintenance +- `setup-cowork` — guided Cowork onboarding +- `context-engineer`, `copywriter` — utility/meta skills + +### Off-Market / Custom +- `off-market-property-search` — off-market lead generation +- `newsletter-generator` — separate from html-email, focused on multi-section newsletters +- `website-builder` — landing page generation + +For any skill not listed above, look in `skills//SKILL.md`. Each skill's SKILL.md is the canonical doc for that skill — read the frontmatter `description` field for triggering keywords. + +--- + +## How Cowork Skills Work (Mechanics) + +Cowork is the runtime. Each skill is a folder with a `SKILL.md` file containing YAML frontmatter (name + description) and Markdown body (instructions). + +**Skill discovery:** Cowork scans the top-level `skills/` directory at session start. Each `SKILL.md` is registered with its description as the trigger string. + +**Skill triggering:** when a user prompt arrives, Cowork's reasoning matches the prompt against every skill's description. The closest match fires — that skill's full instructions become the operating context for the response. + +**Sub-skill referencing:** when a skill's instructions say "Read `../other-skill/SKILL.md`" — that's a regular file read into the current session's context. It doesn't formally invoke the other skill as a separate sub-call. The current skill's session reads the referenced file and follows those instructions inline. + +**Why this matters for architecture:** +- Skills MUST live at `skills//` to be discoverable +- Sub-modules nested inside skills (e.g., `content-creation-engine/modules/newsletter/`) are NOT independently triggerable — they're reference files the parent skill reads +- For a skill to be both standalone AND part of an engine, it MUST be at top level + the engine references it via path + +**Plugin manifest** (`.claude-plugin/plugin.json`) describes the skills bundle for Cowork to load. + +--- + +## Identity & Brand Hard Rules + +### identity.json Is the Single Source of Truth + +`skills/shared-references/identity.json` contains: +- Graeham's name, title, brokerage, **DRE**, phone, email, website +- Primary + secondary markets +- Blocklist of values that must NEVER appear in outputs + +**The DRE is `01466876`.** The blocklist contains one value (a wrong DRE that has leaked into outputs 11 times historically — see CLAUDE.md root for post-mortems). + +### Hard Rules + +1. **NEVER hardcode brand identity from memory or context.** Every skill that emits brand details (CMA reports, listing remarks, schema markup, signatures, footers) MUST read identity.json at generation time. + +2. **NEVER type a DRE-shaped string from prior context.** If a session's reasoning sees a DRE value in its working context, do NOT type it into output. Read identity.json fresh. + +3. **`scripts/verify_brand_identity.py` is the tripwire.** It scans the entire repo for blocked values and exits non-zero if found. Run before every push: + ```bash + python3 scripts/verify_brand_identity.py + ``` + +4. **Documentation-exempt files** (CLAUDE.md root, identity.json itself, verify_brand_identity.py) MAY contain blocked values for warning/blocklist purposes. The tripwire skips them. Other files MUST NOT contain blocked values, even in warnings. + +5. **Cowork's cached skill descriptions can be stale.** If you see the wrong DRE in a skill's description string in Cowork's UI, that's a cache issue, not a real leak. The fix is restarting Cowork to refresh. + +### What Mehmood Needs to Do + +When building features that emit brand details: +- Read identity.json at runtime — never hardcode +- Test that the tripwire passes before committing +- Don't include literal blocklist values in code comments or docs (use abstract references like "the blocklisted value documented in identity.json") + +--- + +## Integrations + +See `skills/shared-references/integrations.md` for the canonical integration matrix. Key points: + +- **13 active integrations** (MLSListings, GSC, Apify Reddit, Apify Zillow, YouTube transcriber, YouTube Data API, Instagram, Facebook, EPA gov, HeyGen, ElevenLabs, GoHighLevel, GitHub) +- **3 stale / needs verification** (Apify Zillow, YouTube Direct API, GSC Direct API) +- **3 pending** (Reddit official API — applied; Santa Clara county records — not wired; San Mateo county records — not wired) +- **Windsor + Direct API parallel-pull rule** — for any source available via both, pull both in parallel, compare freshness/completeness, pick winner. Documented in integrations.md with pseudocode. + +When wiring a new integration: +1. Add an entry in integrations.md +2. Update the per-skill map at the bottom of that file +3. If the integration touches identity-related fields, update verify_brand_identity.py's tripwire +4. Run verification before declaring it production-ready + +--- + +## Pantana Reference (Category Context) + +Jason Pantana ships a real-estate AI content kit (sometimes called "PropCast" — distinct from Graeham's project despite the name overlap). It's mostly content-side and template-shaped: agents download the templates, swap placeholder values for their own market, and run. + +**Where Pantana overlaps PropCast:** +- BOFU query patterns (his query library inspired Graeham's bofu-query-generator) +- BOFU scoring framework (Intent Matrix concept) +- Listing remarks writer (Pantana's "nouns over pronouns" approach) +- Listing photo captioner +- Price reduction angle generator +- Blog post writer (AEO structure) +- YouTube-to-blog pipeline +- Channel scraper + +**Where PropCast goes further:** +- Transaction-side stack (CMA, offer analysis, disclosure analysis) — Pantana doesn't have these +- Hyperlocal Bay Area / EPA context baked into every skill +- Branded output (CMA reports, dashboards) with Graeham's identity +- GitHub Pages publishing pipeline for client-facing URLs +- GoHighLevel CRM integration for comment-keyword lead capture +- ElevenLabs + HeyGen production pipeline (avatar voice + video) +- Two-score architecture (Opportunity vs Intent) +- Three-tier email + dashboard buttons targeted at specific team members (Blog Track vs Peter) +- Windsor MCP integration for cross-platform analytics +- Apify-driven Reddit ideation +- Three-strategy CMA pricing framework (Graeham's specific methodology) + +**Why this matters for build priorities:** Pantana ships what most agents would build first (templates). PropCast's moat is the depth on the transaction side and the integration layer. Don't spend time replicating Pantana's templates — use them as references where they're already in our repo, and focus build effort on the parts Pantana doesn't have. + +**Files in this repo that originated from Pantana's templates:** +- `Cotent Creation engine Jason Pantana/` (in Graeham's local Documents folder, not in repo) — the original Pantana download. Used as reference during the April 2026 audit. NOT in the skills repo. +- Pantana's BOFU Query Generator + BOFU Scorer were absorbed into our skills (reorganized + Bay Area localized). Our `bofu-query-generator/SKILL.md` and `bofu-intent-scorer/SKILL.md` are the canonical versions. +- Pantana's blog post writer additions (JSON-LD schema, RSS internal linking, YouTube embed patterns) were folded into `content-creation-engine/references/phases/script-writer/references/` rather than living as a separate blog-post-writer skill. + +--- + +## Open Work / Known Issues + +Tracked as of April 2026: + +### High priority + +1. **`single-topic-dashboard-builder.py` function-body refactor** — the v5 builder has the loader fixed and button-render logic updated for the new 3-button-per-video-format pattern, but the render code still runs at module-load time with empty dicts. Wrapping it inside `_render_html()` is a ~1000-line indentation refactor that needs bash + Python AST verification. Deferred from April 30 session due to bash sandbox failure. See file's NOTICE block at top. + +2. **Email generator script** — `weekly-email-builder.py` doesn't exist yet. Spec for the email format is in `content-calendar/SKILL.md` (Weekly Email Format section). Builder script should produce `outputs/emails/weekly-{date}-blog.html` and `daily-{date}-blog.html` from the weekly calendar JSON. + +3. **County records integrations** — Santa Clara + San Mateo. Spec in integrations.md. Build a `county-records-scraper` standalone skill that takes county + APN as input, returns parcel JSON. + +### Medium priority + +4. **Verify Apify Zillow scraper** — flagged stale in integrations.md. Run a test scrape on a known address before relying on it for time-sensitive output. + +5. **Verify YouTube Data API + GSC Direct API OAuth flows** — both flagged stale. Confirm token refresh handling. + +6. **Reddit official API follow-up** — see Cloud Chrome prompt in integrations.md. When approved, document the new connector and update content-ideation-engine to parallel-pull with Apify scraper. + +7. **Tripwire extension to `online-content` repo** — currently `verify_brand_identity.py` only audits the skills repo. The April 29 leak was IN the published-content repo (then `cma-reports`, now `online-content`). Either copy the script to `online-content` OR extend this script to clone-and-audit `online-content` as part of its run. + +### Low priority / future + +8. **Google Trends MCP** — currently uses generic web search via Chrome. If a reliable Trends MCP appears, switch. + +9. **Skill-calls-skill via Skill tool** — currently engines reference standalones via `Read` (sibling-path file read). Could migrate to formal `Skill` tool invocation for better separation. Not urgent. + +10. **Content-creation-engine Phase 5 reference file split** — `instructions.md` is large. Could be split into smaller per-format files. + +--- + +## Build Priorities + +If you're starting fresh and asking "what should I work on?", priority order: + +### Week 1: Verify what exists +1. Read this entire doc + identity.json + integrations.md +2. Pull the repo via GitHub Desktop +3. Run `python3 scripts/verify_brand_identity.py` (it should pass — confirm) +4. Open `skills/content-creation-engine/SKILL.md` end-to-end +5. Open `skills/content-calendar/SKILL.md` end-to-end +6. Try a real run: in Cowork, ask "build me a content package on AB 1482 for Bay Area landlords" and trace which skills fire and in what order + +### Week 2: Knock out the high-priority open work +1. Finish the single-topic-dashboard-builder.py function-body refactor (item #1 in Open Work) +2. Build weekly-email-builder.py (item #2) +3. Wire county records (item #3) + +### Week 3+: Verify integrations + extend +1. Test stale integrations (Apify Zillow, Direct APIs) +2. Follow up on Reddit API (use the Cloud Chrome prompt in integrations.md) +3. Extend tripwire to `online-content` (item #7) + +### Ongoing: Productize +- Once Graeham's specific build is solid, evaluate productizing for other agents (the PropCast SaaS direction). That's a separate project — multi-tenant identity.json, tenant-isolated dashboards, billing, etc. + +--- + +## How to Develop on This Repo + +### Local Setup + +1. Install GitHub Desktop (Mac or Windows) +2. Clone `Graehamwatts/skills` via GitHub Desktop +3. Local path: `~/Documents/GitHub/skills` (Mac) or `C:\Users\{user}\Documents\GitHub\skills` (Windows) +4. Verify: `cd skills && ls` should show README.md, CLAUDE.md, skills/, scripts/, etc. + +### Editing Workflow + +1. Open the repo in your editor of choice (VS Code recommended) +2. Edit any SKILL.md or reference file +3. Run `python3 scripts/verify_brand_identity.py` to check for DRE leaks +4. Commit via GitHub Desktop with a descriptive message +5. Push via GitHub Desktop +6. Cowork will pick up the updated skills on the next session (may need to restart Cowork to force refresh) + +### Testing a Skill + +Without bash access (which is the case in standard Cowork sessions): +- Read the SKILL.md end-to-end +- Trace what files/scripts it references +- Read those references +- Look for hardcoded paths that point to dead sandbox sessions (e.g., `/sessions/something/mnt/...`) — those are stale and need fixing + +With bash access: +- `python3 scripts/verify_brand_identity.py` — tripwire check +- `python3 -c "import ast; ast.parse(open('skills/some-skill/script.py').read())"` — syntax check on Python skill scripts + +### Common Pitfalls + +1. **Editing the local Cowork plugin folder** — those edits don't persist. Always edit the GitHub Desktop clone. +2. **Hardcoding DRE in code or docs** — read from identity.json at runtime. Tripwire will catch you. +3. **Deleting skills without updating cross-references** — use the Grep tool to find every reference before deletion. +4. **Pushing without running the tripwire** — eventual leak. Run it. +5. **Mistaking Cowork's cached descriptions for current state** — if something's "wrong" in Cowork but right on GitHub, restart Cowork. + +### Naming Conventions + +- Skill folder names: `kebab-case` (e.g., `bofu-query-generator`) +- Reference files: `kebab-case.md` (e.g., `voice-and-style.md`) +- Output files: `{slug}-{date or ts}.{ext}` (e.g., `content-package-2026-04-30.md`) +- GitHub Pages dashboards: `YYYY-MM-DD-{slug}-production.html` +- Commit messages: descriptive imperative (e.g., "Add market-update-narrative module to content-creation-engine") + +### When in Doubt + +1. Check `CLAUDE.md` (root) — it has the high-level rules +2. Check `skills/shared-references/identity.json` — for any brand identity question +3. Check `skills/shared-references/integrations.md` — for any external data source question +4. Check `skills/shared-references/data-contracts.md` — for cross-skill JSON contracts +5. Read the SKILL.md of the skill you're working on +6. Ask Graeham — he has the most context on intent + +--- + +## Last Updated + +April 30, 2026 — Phase 7 deliverable of the Pantana audit + repo restructure. + +Future updates: when integrations are verified, when stale items are resolved, when build priorities shift, or quarterly \ No newline at end of file diff --git a/emails/2026-05-24-peter-june-bimonthly-equity-production-brief.html b/emails/2026-05-24-peter-june-bimonthly-equity-production-brief.html new file mode 100644 index 0000000..5660de4 --- /dev/null +++ b/emails/2026-05-24-peter-june-bimonthly-equity-production-brief.html @@ -0,0 +1,274 @@ + + + + + +June 2026 Bi-Monthly Market Update — Production Brief for Peter + + + + +
+ + +
+
Bi-Monthly Market Update · June 2026
+

Equity & Wealth Position
Bay Area + East Palo Alto

+
Production brief — two videos, one package
+
+ B4 — Equity & Wealth Angle + Format 3: Bi-Monthly + Financial / Advisory Tone +
+
+ + +
+ + +
+ +

Everything you need — full scripts, SSML/voice, shot list, AI video prompts, platform copy, and YouTube SEO — is in the production dashboard below. This email is your quick-reference summary.

+ +
+ +
+ + +
+ +
+ This is the June bi-monthly update (every 2 months, Bay Area + EPA). We deliberately skipped the buyer-behavior and seller-timing angles — they felt too close to the May market recap. This month's angle is B4: Equity & Wealth Position — aimed at long-term homeowners who aren't actively buying or selling but are starting to think about their next move. +
+

Audience: people who bought 5–12 years ago and haven't thought hard about what their equity actually means in options. This is not a market hype video. It's an advisory video — think financial planner, not listing agent.

+
+ +
+ + +
+ + +
+
Bay Area / San Mateo County
+

Your Peninsula Equity: $820K and Three Options

+
"If you bought in San Mateo County before 2020, there's a number you should know — and most homeowners have no idea what it is."
+

The average San Mateo County homeowner who bought in 2015 is sitting on approximately $820,000 in equity. The video walks through three options: sell (and what the net looks like), refinance or HELOC (when this makes sense), and the 1031 exchange (for owners thinking about investment property).

+
GHL: EQUITY
+
+ +
+
East Palo Alto
+

EPA Long-Term Owners: $650K in Equity — Here's What That Means

+
"If you bought in East Palo Alto before 2014, you're likely sitting on around $650,000 in equity. And the math of what to do with it is more complicated than you think."
+

EPA-specific equity story with Prop 13 savings math ($5,200/yr vs. $13,400/yr if reassessed), capital gains calculation (~$40K–$50K tax on $150K taxable gain after exclusion), and realistic net proceeds (~$550K–$600K). Gives long-term owners a real number to work with.

+
GHL: OPTIONS
+
+
+ +
+ + +
+ +
+
Direction for both videos
+
    +
  • Financial / advisory register — calm, measured, authoritative. NOT market hype, NOT urgency.
  • +
  • Speak to the camera like a trusted financial advisor at a kitchen table, not like a listing agent at an open house.
  • +
  • Pacing is slower and more deliberate than a buyer-strategy video — let the numbers land.
  • +
  • Visuals: cool-neutral aesthetic (navy + gold). Equity gauge graphics, hands reviewing documents, clean data overlays. No "SOLD" signs, no bidding war energy.
  • +
  • Thumbnail concept: equity gauge graphic, bold "YOUR EQUITY NOW" text, cool financial palette — deliberately different from last update's warm aerial style.
  • +
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#ShotMarketNotes
1Talking head — hook delivery, direct eye contactBothGraeham seated, calm framing. No motion graphics behind — keep it clean.
2On-screen equity number reveal ($820K / $650K)BothBig number, full frame, 1.5-sec hold. Let it breathe.
3Three-options graphic — sell / refi / 1031Bay AreaMotion graphic overlay or whiteboard-style reveal.
4Prop 13 savings comparison ($5,200 vs $13,400)EPASide-by-side text card. Clear, no clutter.
5Net proceeds breakdown (~$550K–$600K)EPASimple bar or number card. Real numbers, not ranges where possible.
6Talking head — CTA closeBoth"Text EQUITY to 650-308-4727" (Bay Area) / "Text OPTIONS" (EPA)
7B-roll: Peninsula neighborhood dusk dollyBay AreaAI video (Seedance 2.0 prompt in dashboard). Cool-neutral grade, no golden hour warmth.
8B-roll: EPA street-level slow push-inEPAAI video (Seedance 2.0 prompt in dashboard). Long-hold, still framing — matches advisory tone.
+
+ +
+ + +
+ + +
+
Prompt 1 — Equity Gauge Graphic (motion graphic, both videos)
+

Animated equity gauge graphic on dark navy background (#1B2A4A). Gold needle sweeps from left to center-right, settling on a bold "$820,000" readout. Clean sans-serif typography. Subtle particle ambient light. Financial data-viz aesthetic. Cool-neutral color palette. 5-second loop, no camera movement. 4K, 9:16 vertical.

+
+ +
+
Prompt 2 — Peninsula Dusk Dolly (Bay Area B-roll)
+

Slow dolly forward along a quiet San Mateo County residential street at dusk. Cool-blue twilight sky, warm interior house lights visible through windows. Mature trees lining the street. No people. 35mm cinematic lens, shallow depth of field. Cool-neutral grade — deliberately NOT golden hour warm tones. 6 seconds. 4K, 9:16 vertical.

+
+ +
+
Prompt 3 — EPA Long-Term Home Push-In (EPA B-roll)
+

Ultra-slow push-in toward the front door of a modest well-maintained East Palo Alto single-family home. Exterior shot, late afternoon cool light. Established neighborhood feel — mature landscaping, clean paint. No people visible. Handheld-stable, almost imperceptible camera drift forward. Quiet, contemplative mood. 6 seconds. 4K, 9:16 vertical.

+
+
+ +
+ + +
+ +
+ DRE# 01466876 — must appear in both videos (lower third or end card). Brokerage: Intero Real Estate. +
+
+ GHL keyword capture: Bay Area video → text EQUITY to 650-308-4727. EPA video → text OPTIONS to 650-308-4727. +
+
+ Full scripts, SSML, YouTube SEO, and platform copy are all in the dashboard — click the button above. The SSML tab has the ElevenLabs-ready voice script for both markets. +
+
+ +
+ + + + +
+ + \ No newline at end of file diff --git a/emails/market-update-production-brief-may-2026.html b/emails/market-update-production-brief-may-2026.html new file mode 100644 index 0000000..5058621 --- /dev/null +++ b/emails/market-update-production-brief-may-2026.html @@ -0,0 +1,482 @@ + + + + + +Production Brief — EPA + Bay Area Market Update Videos (May 2026) + + + + +
+ + +
+
Production Brief • Graeham Watts
+

Market Update Videos
EPA + Bay Area — May 2026

+
Scripts • Shot Lists • B-Roll Prompts • Thumbnail Concepts
+
+ May 25, 2026 + 2 Videos + Data: MLSListings Apr–May 2026 + DRE# 01466876 +
+
+ + +
+ + + 🔗 Full Hosted Production Brief + graehamwatts.github.io/skills/emails/market-update-production-brief-may-2026.html + + +
+

Hey Peter — two market update videos below, ready to shoot. Each section has the full script, shot list, b-roll prompts (Seedance + Higgsfield), and thumbnail concept — all with one-click copy buttons so you can paste straight into your workflow.

+

Both videos: Graeham talking-head on warm desk look • ~90–120 sec each • No equity/tax talk — straight market data, fun and energetic.

+
+ +
+ + +
+
+
1
+
+
East Palo Alto Market Update — May 2026
+
GHL keyword: MARKET • Audience: EPA buyers + sellers • ~2:45
+
+
+
+ +
April 2026 Data (MLSListings)
+
+
$1.2M
Median Sale Price
+
102%
List Price Rec'd
+
34 days
Median DOM
+
9
Closed Sales
+
12
New Listings
+
$765/sf
Avg Price/SqFt
+
+ +
Hook (0:00–0:05 open frame)
+
+
▶ Say this first — camera rolling
+

“In East Palo Alto right now, homes are selling for $1.2 million — and buyers are paying more than asking price. So why is demand still this strong in a market this quiet?”

+
+ +
Full Script (click to copy)
+
+ +
+
0:00–0:30 • HOOK
+

In East Palo Alto right now, homes are selling for $1.2 million — and buyers are paying 102 cents on the dollar. That means on a million-two home, you are not getting a deal. You are paying over ask. So the question is: why is demand still this strong in a market this quiet?

+

I’m Graeham Watts, I sell real estate in East Palo Alto, and I just pulled the April 2026 numbers straight from MLSListings. Let me show you what they say.

+
0:30–0:42 • CTA
+

Before I get into the data — drop "MARKET" in the comments and I’ll send you the full EPA breakdown for your specific street. Free. No pitch. Now, here’s what the numbers show.

+
0:42–2:15 • DATA BREAKDOWN
+

Question: What is the median home price in East Palo Alto right now?

+

As of April 2026, the median sale price in East Palo Alto is $1,200,000. That’s based on 9 closed sales last month. Small number — this is a thin market — but it’s consistent with what we’ve been seeing all year.

+

Question: How long are homes sitting before they sell?

+

Median days on market is 34. But here’s the split: the homes that are priced right are gone in under two weeks. The ones that sit are usually priced above comp — and they’re pulling that 34-day median up. If you’re priced right in EPA, you’re not waiting a month.

+

Question: Are buyers paying over asking?

+

Yes. 102% of list price on average. That’s not a bidding war frenzy, but it means sellers are not giving anything away. If you walked in expecting to negotiate down — this data says the market doesn’t support that right now.

+

Question: How much inventory is there in East Palo Alto?

+

12 new listings came to market in April. 9 sold. That’s it. EPA is a small market by design — bounded by the 101, the Bay, and Palo Alto. There is no flood of new inventory coming. What you see is what you get.

+
2:15–2:45 • CONTEXT
+

Here’s the bigger picture. San Mateo County’s median dropped year-over-year. EPA did not. This market is holding. And with only 12 listings a month, if you’re a buyer waiting for a deal — you’re competing with everyone else who is also waiting for a deal. The people who bought in 2024 when it felt uncomfortable? Those are the people who own in EPA right now.

+
2:45–3:00 • CLOSE + CTA
+

I’m Graeham Watts with Intero Real Estate — DRE 01466876. I specialize in East Palo Alto. Drop "EPA" below and let’s talk about your specific situation. I’ll pull the data for your block.

+
+
+ + +
AEO: Q&A structure mirrors how buyers search ChatGPT/Perplexity. Each answer is date-anchored (“As of April 2026”) for Google AI Overviews pickup.
+ +
Shot List (click to copy)
+
+ + + + + + + + + + + +
#ShotNotes
1Open talking headGraeham at desk, warm lit, direct-to-camera. Conversational, leaning in.
2Stat overlay: $1.2MBurn-in: “$1,200,000 — EPA Median Sale Price • April 2026”
3B-roll: EPA aerialNeighborhood from above, tree canopy, Bay in background. 4–6 sec.
4Stat overlay: 102%“102% of List Price • Buyers Paying Over Ask” — animated text reveal
5B-roll: Street levelQuiet EPA residential block, Sold sign on well-kept home
6Stat overlay: inventory“12 New Listings • 9 Sold • That’s the whole market”
7B-roll: communityCooley Landing, Bay trail, or community park. Establishes EPA identity.
8Close talking headBack to Graeham for CTA. Hold 3 sec post-CTA for caption text overlay.
+
+ + +
B-Roll Prompts
+ +
+ +
+
B-Roll 1 — EPA Aerial (Seedance 2.0)
+

Smooth cinematic drone pull-back from a quiet residential street in East Palo Alto, California. Single-family homes with mature trees, green lawns. Morning light, golden hour warmth. Bay visible in the far background. No cars moving. Peaceful, hyper-local feel. 4K, slow motion 0.5x, natural color grade, no lens flare.

+
+
+ + +
+ +
+
B-Roll 2 — Sold Sign Street Level (Higgsfield / Kling 3.0)
+

Street-level steady shot of a residential front yard in a quiet California suburb. A red "SOLD" rider is attached to a white real estate sign in the front yard. Lush green landscaping. Warm afternoon light. Slight breeze moves the tree leaves. Camera holds static for 4 seconds, then slowly pushes in on the sold sign. Photorealistic, 24fps cinematic.

+
+
+ + +
+ +
+
B-Roll 3 — EPA Community (Seedance 2.0)
+

Cinematic slow pan across Cooley Landing waterfront, East Palo Alto, California. San Francisco Bay in background, blue sky with light clouds. Empty walking path in foreground. Early morning. Peaceful, community-focused. Slightly wide lens, natural color. No people. Establishes "this specific neighborhood" identity. 4K, smooth gimbal movement.

+
+
+ + +
Thumbnail Concept
+
+
EPA Market Update Thumbnail
+

Layout: Graeham left-frame (shoulder up, pointing at stat), bold stat right-frame.

+

Primary text: “$1.2M” massive gold type. Subtext: “EPA IS STILL HOT” white.

+

Background: Dark navy gradient with faint EPA aerial photo texture.

+

Feel: Confident, hyper-local, data-forward. NOT stock-photo suburban agent vibes.

+
+ +
YouTube thumbnail, dark navy background with subtle aerial neighborhood photo texture, large bold gold text reading '$1.2M' on the right side, smaller white bold text below reading 'EPA IS STILL HOT', professional real estate agent placeholder on the left pointing at the text, gold bottom-border accent bar, clean modern typography, high contrast, no stock photo cliches
+
+
+ + +
+
+ +
+ + +
+
+
2
+
+
Bay Area Real Estate — Spring 2026 Market Update
+
GHL keyword: NUMBERS • Audience: Peninsula buyers + sellers • ~2:45
+
+
+
+ +
12-Month Data (MLSListings, All Residential)
+
+
$1,280,201
May 2026 Avg Price
+
$1,045,278
Dec 2025 Low
+
+$234,923
5-Month Move
+
+11.9%
YoY (vs Jun 2025)
+
+ +
Hook (0:00–0:05 open frame)
+
+
▶ Say this first — camera rolling
+

“Bay Area home prices just hit $1.28 million — the highest average in over a year. And here’s the wild part: in December we were at $1.04 million. That is a $235,000 move in five months.”

+
+ +
Full Script (click to copy)
+
+ +
+
0:00–0:30 • HOOK
+

Bay Area home prices just hit $1.28 million — the highest average sale price in over a year. And here’s the part that’s actually wild: in December 2025, we were at $1.04 million. That is a two-hundred-and-thirty-five-thousand dollar move in five months.

+

I’m Graeham Watts, and I just pulled the 12-month trend from MLSListings. This is the actual data — not the headlines, not vibes. Let me walk you through what’s happening.

+
0:30–0:42 • CTA
+

Drop "NUMBERS" in the comments — I’ll send you the full 12-month trend report for the Peninsula. No cost. Now — here’s the data.

+
0:42–2:15 • DATA BREAKDOWN
+

Question: What happened to Bay Area home prices between December 2025 and May 2026?

+

As of May 2026, the average sale price across Bay Area residential properties tracked by MLSListings reached $1,280,201. That’s up from a 12-month low of $1,045,278 in December 2025. The market dropped through fall and winter — then spring happened, and it came back hard.

+

Question: Why did Bay Area prices spike so fast in spring 2026?

+

Three things happened at once. One: inventory stayed historically low. Homeowners who locked in at 2 and 3 percent mortgage rates are not selling. Two: buyers who sat on the sidelines all winter moved in February — all at the same time. Three: multiple offers came back to the sub-1.5 million range in March. When homes get three, four, five offers, that sale-to-list ratio climbs above 100 percent — and it drags the median up fast.

+

Question: Is the Bay Area real estate market going up or down in 2026?

+

As of May 2026, prices are at a 12-month high. Year-over-year, we’re up about 12 percent from June 2025. Whether we hold this level through summer depends on two things: inventory and rates. Right now, both are holding the floor.

+
2:15–2:45 • CONTEXT
+

Here’s what this means depending on where you are. If you bought in 2024 or early 2025 when everyone was nervous — you are already up. If you’re a buyer who has been waiting for prices to come down — the window that opened in December is closing fast. If you’re a seller — spring is historically your best listing window, and right now you have buyer attention, low competition from other sellers, and prices at 12-month highs. That combination doesn’t last all year.

+
2:45–3:00 • CLOSE + CTA
+

I’m Graeham Watts with Intero Real Estate, DRE 01466876 — serving the Peninsula and East Palo Alto. Drop your zip code in the comments and I’ll pull the specific data for your market. I do this every month. It’s free.

+
+
+ + +
AEO: Three Q&A anchors targeting “Bay Area home prices 2026”, “why did Bay Area prices spike”, “is Bay Area real estate going up or down.” Each date-anchored for LLM citation.
+ +
Shot List (click to copy)
+
+ + + + + + + + + + + +
#ShotNotes
1Open talking headGraeham at desk. Energetic — this is a “look at this data” energy video. Lean forward.
2Stat overlay: chartAnimated line chart: $1.14M (Jun 25) dips to $1.04M (Dec 25), surges to $1.28M (May 26). 3-sec reveal.
3B-roll: Peninsula aerialSuburban neighborhoods from above, Bay visible, Silicon Valley sprawl. Wide establishing shot.
4Stat overlay: move“+$234,923 in 5 months” bold text burn-in. High contrast, brief hold.
5B-roll: street levelPalo Alto or Redwood City residential block — well-kept homes, green landscaping, afternoon light.
6Stat overlay: high“May 2026: $1,280,201 • 12-Month High” with subtle upward arrow animation.
7B-roll: interior / open houseStaged living room with natural light. Represents buyer demand returning.
8Close talking headBack to Graeham for CTA. Hold 3 sec post-CTA for caption text.
+
+ + +
B-Roll Prompts
+ +
+ +
+
B-Roll 1 — Peninsula Aerial (Seedance 2.0)
+

High cinematic drone shot pulling back slowly over a dense suburban neighborhood in Silicon Valley, California. Red-tiled roofs, tree-lined streets, mid-century and modern homes mixed. San Francisco Bay visible on the horizon. Late afternoon golden light. Wide establishing shot. 4K, natural color grade, slow 0.4x speed, no artificial lens effects.

+
+
+ + +
+ +
+
B-Roll 2 — Suburban Street (Higgsfield / Kling 3.0)
+

Slow steady tracking shot down a quiet California suburban street. Mature oak trees lining both sides, dappled afternoon light hitting the pavement. Well-maintained single-family homes visible on each side. No cars, no people. Camera moves at walking pace. Warm, aspirational, peaceful. Photorealistic 24fps. Peninsula / Bay Area vibe.

+
+
+ + +
+ +
+
B-Roll 3 — Interior / Open House (Seedance 2.0)
+

Interior of a bright, modern, staged living room in a California home. Large windows letting in natural afternoon light. Clean furniture, white walls, hardwood floors. No people. Slow dolly push from the doorway toward the center of the room. Architectural photography feel. 4K, warm daylight color temperature, no motion blur.

+
+
+ + +
Thumbnail Concept
+
+
Bay Area Market Update Thumbnail
+

Layout: Graeham right-frame (hands open, explaining posture). Stat left-frame.

+

Primary text: “$1.28M” massive white type + gold upward arrow + “12-MONTH HIGH”.

+

Background: Peninsula aerial, darkened + gradient overlay left to right.

+

Feel: Momentum. “Something is happening” energy — urgent without alarmist.

+
+ +
YouTube thumbnail for real estate market update video, background is aerial photo of suburban Silicon Valley California with dark gradient overlay on left half, large bold white text '$1.28M' center-left, gold upward arrow icon below it, smaller gold text '12-MONTH HIGH', real estate agent placeholder on right side with open explaining hand gesture, bottom accent bar with 'MAY 2026 DATA' in small white text, cinematic and data-forward, high contrast
+
+
+ + +
+
+ +
+ + + + +
+ + + + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..c94debd --- /dev/null +++ b/index.html @@ -0,0 +1,229 @@ + + + + + +Main Dashboard -- Graeham Watts Content Engine + + + + + + + + + +
+ + +
+
Main Dashboard · Week of April 27 – May 3, 2026
+

This Week's Content Plan

+
Five topics scored, ranked, and scheduled across Mon–Fri. Click any day to open the production view with scoring, research, and content creation for that topic.
+
+
Goal: Lead Gen
+
5 topics
+
Mix: 20% TOFU / 30% MOFU / 50% BOFU target
+
Generated April 24, 2026
+
+
+ + +
+
+ +
+ Read First + For Peter — How to Use This Dashboard +
+
+
+

This is the weekly plan. Each day tile below is the content scheduled to ship that day. Click a tile to open the production view — that's where you'll find the scripts, copy bank, thumbnail prompts, and the ElevenLabs/HeyGen render commands.

+

Your job, Monday morning: Click Monday's tile. On that page, scroll to the Content Creation section. Click View [Format] Content on YouTube Long to open the script modal. Copy the script + SSML, paste into ElevenLabs, then HeyGen. Move through the other formats in order. Done — move to Tuesday.

+

If a format shows "Awaiting Generation," click the gold-outline Copy Prompt button — it copies a format-specific prompt to your clipboard. Paste into Claude, get the finished content, drop it into the destination platform.

+

If something looks off (wrong day, wrong script, broken button) tell Graeham directly. Don't edit dashboards yourself. Regeneration is one Claude command.

+
+
+
+ + +

Days of the Week 5 scheduled

+
Click a day tile to open the full production view: scoring & why, research breakdown, and content to create.
+ + + + +

Week Intelligence

+
Context the weekly plan is built on: goal mix, what was cut and why.
+ +
+
+ + 📊 Goal Mix Check + Did the 5 scheduled topics hit the funnel-mix target? + +
+ + + + + + + +
TierTargetActualStatus
TOFU20%0%⚠️ Drift >10% (lead_gen goal prioritized)
MOFU30%20%⚠️ Drift >10%
BOFU50%80%⚠️ Drift >10% (by design — lead_gen focus)
+

Goal = lead_gen, so BOFU-heavy is intentional. Drift flags are informational.

+
+
+
+ +
+
+ + ✂️ Cut Topics (5 candidates scored but not selected) + +
+

Topics considered this week and rejected — visible for audit. See the full weekly calendar for scoring details.

+ View Full Weekly Calendar → +
+
+
+ +
+
+ + 📤 Graeham's Edits (Captured overrides) + +
+

No overrides yet this week. Tell Claude if you want to swap, drop, or add topics — your edits will be captured here and persisted.

+
+
+
+ + +

Architecture & References

+ + +
+ + \ No newline at end of file diff --git a/n8n-workflows/PCFS-CMA-Digest-DAILY+WEEKLY.json b/n8n-workflows/PCFS-CMA-Digest-DAILY+WEEKLY.json new file mode 100755 index 0000000..0efee07 --- /dev/null +++ b/n8n-workflows/PCFS-CMA-Digest-DAILY+WEEKLY.json @@ -0,0 +1,20 @@ +{ + "name": "PCFS — CMA Daily + Weekly Digest", + "_backup_note": "Updated 2026-05-21 by Cowork. Trigger changed from weekly (Mon 9am) to DAILY 9am PT. Build Email code is now day-aware: Monday = CMAs due in next 14 days, Tue–Sun = CMAs due that day only. To = graehamwattsclientcare@gmail.com + graehamwatts@gmail.com. n8n workflow id: LHGnZC2X2KKXljB0.", + "trigger": { + "node": "Daily @ 9am PT", + "type": "scheduleTrigger", + "cron": "0 9 * * *", + "timezone": "America/Los_Angeles" + }, + "build_email_logic": "const isMonday = (tzNow.getDay() === 1). If Monday: CMAs where due date between now and now+14d. Else: CMAs where due date is today (between todayMidnight and todayEnd). Header, intro, empty-state, and subject all switch between 'Next 2 Weeks'/'Today' based on isMonday.", + "recipients": { + "to": "graehamwattsclientcare@gmail.com,graehamwatts@gmail.com" + }, + "manual_fire_webhook": "pcfs-cma-digest-fire", + "source_sheet": "https://docs.google.com/spreadsheets/d/1PtfGzUvjJOz5qNmA5173MqmKO9cLCevAhcP12pFeG3s/edit", + "credentials_used": { + "google_sheets": "AkBUwX11QA8RRHec (Google Sheets account)", + "gmail": "DtB2QyzcO239Eb5l (Gmail OAuth2 API)" + } +} diff --git a/n8n-workflows/PCFS-Sharon-Handwritten-Notes-DAILY+WEEKLY.json b/n8n-workflows/PCFS-Sharon-Handwritten-Notes-DAILY+WEEKLY.json new file mode 100755 index 0000000..65d18e1 --- /dev/null +++ b/n8n-workflows/PCFS-Sharon-Handwritten-Notes-DAILY+WEEKLY.json @@ -0,0 +1,21 @@ +{ + "name": "PCFS — Sharon Daily + Weekly Handwritten Notes", + "_backup_note": "Updated 2026-05-21 by Cowork. Trigger changed from weekly (Mon 8am) to DAILY 8am PT. Build Email code is now day-aware: Monday = full week roster, Tue–Sun = that day's notes only. To = sharonpwatts@gmail.com, CC = graehamwattsclientcare@gmail.com + graehamwatts@gmail.com. n8n workflow id: 7CxqNkCQAuw1noGL.", + "trigger": { + "node": "Daily @ 8am PT", + "type": "scheduleTrigger", + "cron": "0 8 * * *", + "timezone": "America/Los_Angeles" + }, + "build_email_logic": "const isMonday = (tzNow.getDay() === 1). If Monday: notes where Week of (Mon) === thisMondayStr (full week). Else: notes where Date === today (__sameToday). Header, intro, empty-state, and subject all switch between 'This Week'/'Today' based on isMonday.", + "recipients": { + "to": "sharonpwatts@gmail.com", + "cc": "graehamwattsclientcare@gmail.com,graehamwatts@gmail.com" + }, + "manual_fire_webhook": "pcfs-sharon-notes-fire", + "source_sheet": "https://docs.google.com/spreadsheets/d/1PtfGzUvjJOz5qNmA5173MqmKO9cLCevAhcP12pFeG3s/edit", + "credentials_used": { + "google_sheets": "AkBUwX11QA8RRHec (Google Sheets account)", + "gmail": "DtB2QyzcO239Eb5l (Gmail OAuth2 API)" + } +} diff --git a/n8n-workflows/README.md b/n8n-workflows/README.md new file mode 100755 index 0000000..5797de8 --- /dev/null +++ b/n8n-workflows/README.md @@ -0,0 +1,11 @@ +# n8n Workflow Backups + +Reference copies and change summaries for n8n workflows on `n8n.graehamwattsn8n.com`. These are NOT importable JSON exports — the workflows live in n8n cloud and are edited there. These files document what the workflows do and what changed. + +## Workflows tracked here + +| Workflow | n8n ID | Trigger | Notes | +|---|---|---|---| +| PCFS — Sharon Daily + Weekly Handwritten Notes | `7CxqNkCQAuw1noGL` | Daily 8am PT (cron `0 8 * * *`) | Mon = full week roster; Tue–Sun = today's note(s) only. Changed from weekly to daily 2026-05-21. | +| PCFS — CMA Daily + Weekly Digest | `LHGnZC2X2KKXljB0` | Daily 9am PT (cron `0 9 * * *`) | Mon = next 2 weeks; Tue–Sun = CMAs due today. Changed from weekly to daily 2026-05-21. | +| PCFS — CMA Autobuild Watchdog | `SMQMpqyKWQVBkiZs` | Mon 11am PT (cron `0 11 * * 1`) | Watches the Cowork autobuild task. Pulls due-list webhook + searches Gmail. Alerts Graeham if expected CMA review emails are missing. Created 2026-05-26. | diff --git a/online-content/2026-05-11-kawajla-n8n-migration.html b/online-content/2026-05-11-kawajla-n8n-migration.html new file mode 100644 index 0000000..8eea376 --- /dev/null +++ b/online-content/2026-05-11-kawajla-n8n-migration.html @@ -0,0 +1,378 @@ + + + + + +N8N Migration — Final Mac Studio Steps + + + + +
+ +
+
Mac Studio · Migration Brief
+

N8N Self-Host — Final Steps After DNS Propagation

+

Cloudflare Tunnel verification + N8N webhook URL switch + sanity checks

+
+ +
+ +
+
Domain: graehamwattsn8n.com
+
Subdomain: n8n.graehamwattsn8n.com → Mac Studio
+
Tunnel name: propos-n8n (Cloudflare Named Tunnel)
+
N8N license key: sent separately via secure channel
+
+ +

Context. Nameservers for graehamwattsn8n.com were pointed from GoDaddy to Cloudflare. Cloudflare already has a Named Tunnel called propos-n8n with the subdomain n8n.graehamwattsn8n.com routing to the Mac Studio's N8N instance on localhost:5678. Once DNS finishes propagating (15 min – 24 hrs), the new URL should resolve. The four steps below confirm the tunnel side is healthy, tell N8N to use the new domain for webhook URLs, and verify the whole chain end-to-end.

+ +
+ Heads up: Don't cancel the N8N Cloud subscription until the parallel run on self-host has been verified for a full week. Webhooks fail silently if anything is off, and we don't want to discover that the day after we cancel. +
+ + +
+ Step 1 +

Verify the Cloudflare tunnel is running

+

Why: the tunnel is what bridges the public URL to the local N8N. If it's not running, nothing else matters.

+
+ +

Open Terminal on the Mac Studio and run:

+
cloudflared tunnel list
+cloudflared tunnel info propos-n8n
+ +

Expected: a tunnel named propos-n8n with one or more active connectors. If "connectors" shows 0, the tunnel exists in Cloudflare but isn't actually running on this machine right now — go to Step 2.

+ + +
+ Step 2 +

Install the tunnel as a launchd service

+

Why: this makes the tunnel auto-start on every Mac boot and auto-restart if it crashes. Quick Tunnels (the temporary trycloudflare.com URLs) die when the terminal closes — we don't want that.

+
+ +
sudo cloudflared service install
+sudo launchctl start com.cloudflare.cloudflared
+ +

Check status afterwards:

+
sudo launchctl list | grep cloudflared
+ +

If cloudflared service install complains about a missing config file, create one at ~/.cloudflared/config.yml:

+ +
tunnel: propos-n8n
+credentials-file: /Users/<USER>/.cloudflared/<TUNNEL-UUID>.json
+
+ingress:
+  - hostname: n8n.graehamwattsn8n.com
+    service: http://localhost:5678
+  - service: http_status:404
+ +

The <TUNNEL-UUID>.json file should already be in ~/.cloudflared/ from when the tunnel was created. Replace <USER> with the actual macOS username.

+ + +
+ Step 3 +

Set N8N's WEBHOOK_URL to the new domain

+

Why: without this, every webhook trigger URL that N8N generates internally still points at localhost:5678 or the old temporary trycloudflare.com address. Anything that hits N8N from outside (DocuSign Connect Bridge, GHL Past Client Receiver, Lead Intake Flow) will silently break.

+
+ +

The exact command depends on how N8N was installed on this Mac. Three scenarios — pick the one that matches:

+ +
Option A — Docker (most common)
+
docker stop n8n
+docker rm n8n
+docker run -d --restart=always --name n8n \
+  -p 127.0.0.1:5678:5678 \
+  -e N8N_HOST=n8n.graehamwattsn8n.com \
+  -e N8N_PROTOCOL=https \
+  -e N8N_PORT=5678 \
+  -e WEBHOOK_URL=https://n8n.graehamwattsn8n.com/ \
+  -e N8N_LICENSE_ACTIVATION_KEY=<SEE_SEPARATE_MESSAGE> \
+  -v ~/.n8n:/home/node/.n8n \
+  n8nio/n8n
+ +

The -p 127.0.0.1:5678:5678 binds N8N to localhost only — external access is handled by the Cloudflare tunnel, so we don't want N8N exposed on the open network.

+ +
Option B — npm install (global)
+

For npm-installed N8N, env vars must be set in the shell environment that launches n8n, not in ~/.n8n/.env. The cleanest way is a pm2 ecosystem file:

+ +
# Create ~/n8n-ecosystem.config.js with:
+module.exports = {
+  apps: [{
+    name: 'n8n',
+    script: 'n8n',
+    env: {
+      N8N_HOST: 'n8n.graehamwattsn8n.com',
+      N8N_PROTOCOL: 'https',
+      N8N_PORT: '5678',
+      WEBHOOK_URL: 'https://n8n.graehamwattsn8n.com/',
+      N8N_LICENSE_ACTIVATION_KEY: '<SEE_SEPARATE_MESSAGE>'
+    }
+  }]
+};
+
+# Then:
+pm2 delete n8n 2>/dev/null
+pm2 start ~/n8n-ecosystem.config.js
+pm2 save
+pm2 startup    # follow the printed sudo command to make pm2 boot on restart
+ +

If pm2 isn't installed: npm install -g pm2 first.

+ +
Option C — N8N Desktop app
+
+ If we're on the Desktop app, we should switch. The Desktop app doesn't expose a clean way to set WEBHOOK_URL, and it's designed for solo personal use — not for serving production webhooks behind a public tunnel. Recommend reinstalling via Docker (Option A) before continuing. The workflow data in ~/.n8n carries over automatically because Docker mounts the same folder. +
+ + +
+ Step 4 +

Verify the whole chain end-to-end

+

Why: each link can fail independently — DNS, tunnel, N8N container, env vars. We want to confirm all four are good before declaring victory.

+
+ +

Local check (N8N itself is up):

+
curl -I http://localhost:5678
+

Expected: HTTP/1.1 200 or 302. Anything else, N8N isn't running.

+ +

DNS check (propagation is done):

+
dig +short n8n.graehamwattsn8n.com
+

Expected: returns a Cloudflare IP (usually starts with 104. or 172.). If it returns nothing, wait longer.

+ +

Public check (full chain):

+
curl -I https://n8n.graehamwattsn8n.com
+

Expected: HTTP/2 200 with a valid Cloudflare certificate.

+ +

Browser check: open https://n8n.graehamwattsn8n.com on any device. You should see the N8N login screen with a valid HTTPS lock.

+ +
+ Error decoder: +
    +
  • Cloudflare 522 — tunnel isn't running. Go back to Step 1 / Step 2.
  • +
  • Cloudflare 530 / 1033 — tunnel is registered but the hostname isn't bound. Check DNS records in Cloudflare dashboard.
  • +
  • "This site can't be reached" — DNS hasn't propagated yet. Wait longer (up to 24 hrs).
  • +
  • SSL warning — Cloudflare hasn't issued the edge cert yet. Usually resolves in 5–10 min after DNS lands.
  • +
+
+ + +
+ Step 5 +

Webhook smoke test

+

Why: the whole point of this migration is making webhooks work from the public internet. Confirm before declaring done.

+
+ +

In the N8N UI at https://n8n.graehamwattsn8n.com:

+
    +
  • Open any workflow with a Webhook trigger node.
  • +
  • Look at the "Production URL" shown in the trigger. It should start with https://n8n.graehamwattsn8n.com/webhook/...not localhost and not a trycloudflare.com URL.
  • +
  • If it still shows the old URL, the WEBHOOK_URL env var didn't take effect. Recheck Step 3.
  • +
  • From a different machine, hit that production URL with curl or Postman. Confirm the workflow executes.
  • +
+ +
+ +

If something breaks, send back these outputs

+

To debug remotely without me being at the Mac, paste the output of all of these:

+
cloudflared tunnel info propos-n8n
+sudo launchctl list | grep cloudflared
+curl -I http://localhost:5678
+dig +short n8n.graehamwattsn8n.com
+docker ps | grep n8n    # or: pm2 status
+ +

Plus a screenshot of whatever error https://n8n.graehamwattsn8n.com shows in the browser.

+ +
+ What happens after this works: we update the Claude N8N MCP config on the Windows machine to point at https://n8n.graehamwattsn8n.com instead of the cloud URL (one line in %APPDATA%\Claude\claude_desktop_config.json), then start migrating cloud workflows one at a time. We do not cancel the N8N Cloud subscription until parallel run has been clean for a full week. +
+ +
+ + + +
+ + diff --git a/online-content/dashboards/switchy/index.html b/online-content/dashboards/switchy/index.html new file mode 100644 index 0000000..6ddb287 --- /dev/null +++ b/online-content/dashboards/switchy/index.html @@ -0,0 +1,106 @@ + + +Switchy Clicks Dashboard — Graeham Watts + + +

Switchy Clicks Dashboard ALL SOURCES

+
Generated 2026-06-01 20:03 · vs. 2026-05-29 (switchy-snapshot-2026-05-29.json) · model: 55% match · 10×/30d · $22 CPM
+ +
+
16,006
Total clicks / scans
+
+6
New this week
+
8,802
Targetable audience
+
$1,936
Justified ad budget / mo
+
+ +
+

Where the clicks come from (by Switchy folder)

+ +
+ +
+

Sources breakdown

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Source (folder)LinksClicksNew/wkAudienceBudget/mo
Google Ads448,53704,695$1,033
youtube descrip63,894+62,142$471
Facebook Ads211,7260949$209
GMB EPA updates1452990164$36
Yard Sign QR12820155$34
Blog Links92400132$29
GMB General107156086$0
GAEPAunder1mil1136075$0
adsby bellehave5113062$0
Adsby epaSearch596053$0
Postcard Old193051$0
adsby rwcsearch580044$0
Post card qr768037$0
Unfiled / no source tag1244024$0
GMBRedwood City2844024$0
GARC Homes FS133018$0
buyerlinkghl251427015$0
GHL Calendar li122012$0
Seller Drip822012$0
Sellerlinkghl25721012$0
Buyer Drip Camp120011$0
SHORTS GMB418010$0
Orchard Park11709$0
952 6th AVE1704$0
GMB EPA Product4704$0
GMB East Menlo3402$0
TOTAL44216,006+68,802$1,936
+
+ +
+

Top 15 links

+ + + + + + + + + + + + + + + + + +
LinkSourceShort URLClicksNew/wk
Homes for Sale - Graeham Watts - Intero Real Eyoutube descrippages.graehamwatts.com/bay-area-homes-for-sale1,069+2
Sell Your Home with Graeham Watts - Step-by-StGoogle Adspages.graehamwatts.com/SbSR1,0230
graeham watts - 30 Minute Meeting | TidyCalyoutube descrippages.graehamwatts.com/graehamwatts-meet779+3
How Much is My Home Worth? - Get Free Report -youtube descrippages.graehamwatts.com/whats-my-house-worth7220
How Much is My Home Worth? - Get Free Report |Google Adspages.graehamwatts.com/SKZY6810
Get in Touch with Graeham Watts: Let’s Talk AbGoogle Adspages.graehamwatts.com/SKaA6020
How Much is My Home Worth? - Get Free Report |Google Adspages.graehamwatts.com/SK0r5860
2271 Euclid AVE, East Palo Alto, CA 94303 - MLFacebook Adspages.graehamwatts.com/XhI55700
How Much is My Home Worth? - Get Free Report |Google Adspages.graehamwatts.com/SK1P5470
Sell Your Home with Graeham Watts - Step-by-StGoogle Adspages.graehamwatts.com/SKai5150
Buy Your Home with Graeham WattsGoogle Adspages.graehamwatts.com/SKY85090
Redwood City Homes for Saleyoutube descrippages.graehamwatts.com/redwood-city-homes-for-sale497+1
Buy Your Home with Graeham WattsGoogle Adspages.graehamwatts.com/SKYl4600
East palo alto ca homes for saleyoutube descrippages.graehamwatts.com/east-palo-alto-ca-homes-for-sale4300
Explore East Menlo Park: A Hidden Gem!youtube descrippages.graehamwatts.com/homes-for-sale-belle-haven3970
+
+ +
+ How to read this: Audience = clicks that resolve to a targetable pixeled user (55%). Budget/mo = what that audience can absorb at 10×/30d, $22 CPM — a ceiling, not a target. Sources are the Switchy folders each link lives in; "Unfiled" links need a folder/tag to be attributable. Switchy's API gives click totals only — geo/referrer/device live in GA4 (via UTM) and Meta (via pixel). +
+ + + \ No newline at end of file diff --git a/online-content/dashboards/switchy/monday-email.html b/online-content/dashboards/switchy/monday-email.html new file mode 100644 index 0000000..fa13627 --- /dev/null +++ b/online-content/dashboards/switchy/monday-email.html @@ -0,0 +1,34 @@ + +Your Monday Switchy Report + +
+
+
Monday Switchy Report
+
Where your clicks came from this week
+
+
+ + + + + + +
15,997
Total scans/clicks
8,798
Targetable audience
$1,936
Justified ad $/mo
+

+ Your full breakdown — clicks by source (postcards, Google Business, YouTube, + yard signs, ad campaigns), top links, and week-over-week growth — is on the + live dashboard. It refreshes every Monday. +

+ +

+ Link goes live once the skills repo is pushed to GitHub Pages. Switchy reports + click totals only; geo/referrer/device live in GA4 (via UTM) and Meta (via pixel). +

+
+
+ diff --git a/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-05-28.json b/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-05-28.json new file mode 100644 index 0000000..ec39c4b --- /dev/null +++ b/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-05-28.json @@ -0,0 +1 @@ +{"date": "2026-05-28", "clicks": {"bay-area-homes-for-sale": 1067, "SbSR": 1023, "graehamwatts-meet": 776, "whats-my-house-worth": 722, "SKZY": 681, "SKaA": 602, "SK0r": 585, "XhI5": 570, "SK1P": 547, "SKai": 515, "SKY8": 509, "redwood-city-homes-for-sale": 496, "SKYl": 460, "east-palo-alto-ca-homes-for-sale": 429, "homes-for-sale-belle-haven": 397, "T1td": 396, "SJob": 376, "SJ-k": 296, "MFUy": 281, "SXDB": 234, "SM9A": 205, "SKZH": 190, "SRlP": 185, "SKDr": 180, "SX1b": 170, "SJoH": 163, "SKYx": 141, "SJpt": 141, "east-palo-alto-CA-homes-for-sale-under-1million": 136, "SJ-D": 126, "SX7L": 124, "SK0T": 123, "S-VQ": 116, "T1nG": 111, "SXGz": 108, "SKYW": 105, "East-palo-alto-home-for-sale": 96, "menlo-park-homes-for-sale": 94, "how-much-is-my-home-worth": 93, "T1r5": 90, "S-Vz": 88, "redwood-city-home-for-sale": 80, "T8PU": 78, "TA7m": 71, "SX5l": 70, "SXG2": 70, "S9yj": 67, "XhH8": 61, "schedule-bay-area-real-estate-meeting": 60, "SKYI": 60, "blog-east-palo-alto-ca-homes-for-sale": 59, "S-Vg": 59, "free-home-evaluation": 59, "S9yo": 54, "T1v5": 51, "SM92": 50, "SXBD": 48, "S9xP": 41, "SX4-": 41, "T8Yl": 41, "SKXy": 39, "redwood-city-ca-homes-for-sale": 33, "real-estate-market-forecast-save-thousands-on-your-mortgage-hack": 28, "formly_email": 28, "WVkH": 28, "S9w-": 27, "bay-area-real-estate-market-update-2026-01": 27, "SKXW": 24, "Meet-with-Graeham": 22, "S9xr": 22, "Home-For-Sale": 20, "SM9K": 20, "east-menlo-park": 19, "sell-with-graeham": 19, "5636-orchard-park-drive-san-jose-ca-95123": 17, "why-sell-with-graeham": 16, "discover-east-menlo-park-homes": 15, "Belle-haven-home-value-evaluation": 14, "SKaM": 13, "homes-for-sale-redwood-city-ca": 13, "east-palo-alto-real-estate-sold-40k-more-with-50k-issue": 13, "get_free_consultation": 13, "home-selling-tips-sell-your-home-quickly": 12, "homevalue": 11, "v4Sk": 10, "S9y0": 10, "before_after_1930_Sarah_Dr": 9, "SX9V": 9, "S9z0": 9, "schedule-call-with-graeham": 9, "woodside-plaza-redwood-city-homes-for-sale": 9, "east-palo-alto-condo-1982-w-bayshore-223": 9, "essential-home-selling-tips-5-Biggest-Mistakes": 8, "homes-for-sale-east-palo-alto-ca-discover-your-dream-home": 7, "952-6th-AVE-Redwood-City-CA-94063": 7, "828-Weeks-ST": 7, "homes-for-sale-in-the-bay-area-safety-tips": 7, "free-home-valuation": 7, "Home-Buying-Myths": 7, "redwood-city-real-estate-expert-advice-you-can-trust": 6, "east-palo-alto-real-estate-470-bell-st-tranquil-home-tour-silicon-valley": 6, "real-estate-market-forecast-fed-rate-cuts-2025-housing-market": 6, "bay-area-realtor-graeham-watts-winnie-danny-dream-home": 6, "real-estate-market-update-transformed-sold-for-top-dollar": 6, "east-palo-alto-condos-for-sale-woodland-creek-223": 5, "3-Buyer-Myths": 5, "selling-homes-quickly-tips-maximize-home-value": 5, "east-palo-alto-real-estate-insights-650k-fixer-upper-dream-home": 5, "Home-Evaluation": 5, "bay-area-realtor-dream-home-client-testimonial": 5, "2025-california-landlord-risk-update": 5, "Why-Clients-Trust-Graeham-Watts": 5, "homes-for-sale-in-menlo-park-graeham-watts-tour": 5, "redwood-city-real-estate-cost-of-living": 4, "Proven-Home-Selling-Tips": 4, "east-palo-alto-homes-for-sale-431-larkspur-dr-record-sale": 4, "east-palo-alto-market-update-prices-rising-homes-selling-fast": 4, "KII7": 4, "east-palo-alto-real-estate-470-bell-st-listing": 4, "avoid-costly-homebuyer-errors-redwood-city-real-estate-ca": 4, "SJpA": 4, "real-estate-market-update-313-smithwood-milpitas-ca-95035-home-tour": 4, "Offer-Accepted": 4, "top-east-palo-alto-realtor-sell-home-fast-top-dollar": 4, "top-east-palo-alto-realtor-home-selling-tips": 4, "discover-homes-for-sale-redwood-city-ca-spanish-style-4-bedroom-friendly-acres": 4, "san-jose-home-for-sale-500k-assumable-rate": 4, "How-We-Price-Your-Home": 4, "east-palo-alto-homes-for-sale-123-main-st-tour": 4, "redwood-city-real-estate-monthly-market-update-Graeham-watts": 4, "houses-in-east-palo-alto-modernized-1239-jervis-ave": 4, "East-Palo-Alto-Real-Estate-Monthly-Market-Update-graeham-watts": 4, "east-palo-alto-real-estate-missed-ca-fee-amnesty-act-now": 3, "east-palo-alto-realtor-sell-home-fast-top-dollar": 3, "bay-area-realtor-graeham-watts-testimonial-savings": 3, "1457-quail-st-los-banos-4-bedroom-home": 3, "affordable-east-palo-alto-homes-14-robin-court-tour": 3, "bay-area-realtor-free-home-staging-maximize-value": 3, "real-estate-predictions-2025-east-menlo-park": 3, "redwood-city-real-estate-graeham-watts-love-this-city": 3, "bay-area-realtor-california-rent-rules": 3, "bay-area-realtor-supply-and-demand-toilet-paper-bidding-war": 3, "home-selling-tips-maximize-sale-price-fast": 3, "big-head-big-ideas-big-results-real-estate-predictions-2025": 3, "bay-area-realtor-graeham-watts-johnny-ashley-success": 3, "ca-rent-laws-in-crises": 3, "east-palo-alto-real-estate-market-update-graeham-watts": 3, "essential-home-selling-tips-third-step-home-search": 3, "bay-area-realtor-graeham-watts-ryan-teeda-journey": 3, "1908-Cooley-AVE": 3, "east-menlo-park-market-update-prices-up-inventory-low": 3, "bair-island-homes-for-sale-in-redwood-city": 3, "east-menlo-park-real-estate-march-market-update": 3, "The-Graeham-Watts-Advantage": 3, "homes-for-sale-in-the-bay-area-buy-sell-with-graeham-watts": 3, "redwood-city-march-real-estate-market-update": 3, "discover-east-palo-alto-real-estate-stunning-home-under-900k": 3, "redwood-city-homes-for-sale-hidden-gems-affordable-living": 3, "redwood-city-real-estate-off-market-secrets": 3, "real-estate-market-forecast": 3, "east-palo-alto-market-update-are-you-keeping-up": 3, "east-palo-alto-ravenswood-school-district-transformation": 3, "east-palo-alto-ca-homes-for-sale-woodland-creek-condo-tour": 3, "redwood-city-real-estate-insider": 3, "client-testimonial-moneisha-jermell": 3, "bay-area-realtor-graeham-watts-hidden-gems": 3, "bay-area-realtor-graeham-watts-dream-home-success": 3, "east-palo-alto-ca-house-for-sale-worth-investment": 2, "real-estate-market-update-homes-selling-over-asking": 2, "homes-for-sale-in-east-palo-alto-ca-431-larkspur-dr": 2, "east-palo-alto-real-estate-why-now-is-the-time-to-buy": 2, "belle-haven-market-update": 2, "unleashing-potential-east-palo-alto-real-estate-transformative-fixer-upper": 2, "homes-for-sale-east-palo-alto-ca-117-mission-drive": 2, "bay-area-realtor-graeham-watts-record-home-sale": 2, "redwood-city-homes-for-sale-757-douglas-ave-tour": 2, "homes-for-sale-in-menlo-park-home-value": 2, "east-palo-alto-homes-for-sale-2620-fordham-st-update": 2, "friendly-acres-redwood-city": 2, "redwood-city-condos-for-sale": 2, "redwood-city-living-expense": 2, "east-menlo-park-market-update": 2, "max-profit-selling-homes-redwood-city": 2, "graeham-watts-advantage-redwood-city-real-estate": 2, "east-palo-alto-ca-homes-for-sale-2109-myrtle-pl-tour": 2, "redwood-shores-homes-for-sale-luxury-waterfront-tour": 2, "menlo-park-houses-for-sale-breathtaking-transformation": 2, "houses-for-sale-in-east-palo-alto-1404-camellia-drive": 2, "houses-in-east-palo-alto-952-newbridge-st-real-estate-gem": 2, "bay-area-realtor-graeham-client-testimonial-john-ward": 2, "home-selling-tips-attract-perfect-buyers": 2, "real-estate-market-update-menlo-park-2022-trends": 2, "bay-area-realtor-graeham-watts-trusted-expert": 2, "home-selling-tips-get-top-dollar-for-your-property": 2, "homes-for-sale-in-the-bay-area-los-gatos-home-sells-big": 2, "homes-for-sale-in-the-bay-area-luxury-the-westerly": 2, "redwood-city-real-estate-ca-safer": 2, "redwood-city-real-estate-next-big-investment": 2, "homes-for-sale-in-menlo-park-market-trends-graeham-watts": 2, "bay-area-realtor-home-alone-reaction": 2, "redwood-city-real-estate-january-market-update": 2, "east-menlo-park-market-update-january-2025": 2, "east-palo-alto-real-estate-homeowner-update": 2, "redwood-city-real-estate-charming-dream-homes": 2, "redwood-city-march-real-estate-market-update-2025": 2, "redwood-city-real-estate-market-home-value-insight": 2, "selling-homes-quickly-tips-kitchen-countertops": 2, "SK1C": 2, "SM9O": 2, "east-palo-alto-ca-homes-for-sale-1765-e-bayshore-rd-214": 2, "home-selling-tips-expert-staging-top-dollar": 2, "bay-area-realtor-essential-real-estate-tips": 2, "Home-Search": 2, "redwood-city-real-estate-prices": 2, "east-palo-alto-california-real-estate": 2, "rise-and-fall-of-silicon-valley-bank": 2, "bay-area-realtor-graeham-watts-off-market-dream-homes": 2, "just-sold-east-palo-alto-real-estate-1765-e-bayshore-rd-203": 2, "real-estate-market-update-stunning-home-oakley-59-escher-circle-tour-features": 2, "broadway-masala-redwood-city-indian-food": 2, "east-bay-real-estate-hidden-deals-maximize-sale": 2, "join-bay-area-realtor-graeham-watts-fiesta": 2, "mortgage-rates-forecast-2025-housing-loan-trends": 2, "shocking-bay-area-house-tour-twist": 2, "iBiB": 2, "bay-area-realtor-graeham-watts-humor-and-real-estate": 2, "bay-area-realtor-client-testimonial-trust": 2, "bay-area-realtor-daily-hustle-inside-look": 2, "graeham-watts-client-testimonials": 2, "Choosing-a-Bay-Area-Realtor": 2, "Understanding-Contingencies": 2, "nEnF": 2, "is-now-the-best-time-to-buy-real-estate": 2, "v4S8": 2, "sell-home-prep-tips": 2, "real-estate-market-update-you-need-to-know": 2, "Home-Selling-Tips": 2, "redwood-city-real-estate-ca-silicon-valley-oasis": 2, "graeham-watts-bay-area-realtor-real-estate-success": 2, "bay-area-realtor-graeham-watts-advantage": 2, "houses-for-sale-east-palo-alto-453-okeefe-st": 1, "real-estate-market-forecast-august-trends": 1, "east-menlo-park-market-update-home-prices-up": 1, "Real-Estate-Market-Forecast-Save-Thousands": 1, "bay-area-realtor-success-story-marketing-results": 1, "Why-Clients-Trust-Graeham": 1, "redwood-city-real-estate-charming-2bd-1ba-home-tour-expansive-yard": 1, "real-estate-market-update-top-investment-websites": 1, "east-palo-alto-market-update-free-home-valuation": 1, "east-palo-alto-homes-for-sale-2620-fordham": 1, "belle-haven-market-update-menlo-park": 1, "real-estate-market-update-sell-home-top-dollar": 1, "east-palo-alto-real-estate-market-update-2023": 1, "homes-for-sale-in-the-bay-area-just-listed-hot-properties": 1, "bay-area-realtor-graeham-watts-staging-and-3d-tours": 1, "rising-interest-rates-and-real-estate": 1, "redwood-city-real-estate-update-sell-high-inventory-rising": 1, "redwood-city-real-estate-update-2022-home-value": 1, "Home-buyer-guide": 1, "houses-in-east-palo-alto-dream-homes-for-every-budget": 1, "menlo-park-houses-for-sale-monthly-market-update": 1, "east-palo-alto-homes-for-sale-stylish-loft": 1, "ULo7": 1, "redwood-city-real-estate-market-update-for-homeowners": 1, "east-palo-alto-california-real-estate-trends": 1, "real-estate-market-trends-2025-home-warranty-explained": 1, "real-estate-inventory-trends-low-inventory-market-impact": 1, "east-palo-alto-real-estate-market-update-feb-2025": 1, "homes-for-sale-in-the-bay-area-san-leandro-modern-family-home": 1, "bay-area-realtor-graeham-watts-client-testimonial": 1, "homes-for-sale-in-farm-hill-7210-eagle-ridge-dr-gilroy-ca": 1, "KII1": 1, "essential-home-selling-tips-biggest-seller-mistakes": 1, "east-palo-alto-ca-homes-for-sale-buyer-tips-1765-e-bayshore-rd-204": 1, "menlo-park-real-estate-market-update-latest-trends-insights": 1, "home-selling-tips-fix-now-pay-later": 1, "homes-for-sale-in-redwood-city": 1, "real-estate-market-forecast-shocker-haunting-2025": 1, "home-selling-tips-out-of-state-overwhelmed-help": 1, "real-estate-update-home-selling-fast": 1, "rare-redwood-city-real-estate-fixer-2-bed-condo-hidden-potential": 1, "redwood-city-real-estate-insights": 1, "game-changing-real-estate-predictions-2025-big-deals-big-results": 1, "home-selling-mistakes-to-avoid": 1, "best-time-to-buy-real-estate-1930-pinole-drive-tour": 1, "ab-1482-unlocked-secrets-every-california-landlord-should-know": 1, "scariest-real-estate-costume-ever": 1, "redwood-city-real-estate-market-update-prices-up-inventory-down": 1, "home-alone-sequel-we-always-wanted-east-palo-alto-homes": 1, "unlock-hidden-redwood-city-real-estate-ca-gems": 1, "beat-the-market-east-palo-alto-market-update-secret-price-bump": 1, "redwood-city-real-estate-renovation-alert": 1, "2842-Cornelius-Dr-Tour": 1, "Featured-Property-menlo-park": 1, "Featured-Property-Redwood-City": 1, "Featured-Property-east-palo-alto": 1, "east-palo-alto-homes-for-sale-1765-bayshore-rd-203": 1, "homes-for-sale-in-east-palo-alto-ca-2398-palgas-ave": 1, "redwood-city-real-estate-showdown": 1, "redwood-city-real-estate-2022-sell-high-find-home": 1, "east-palo-alto-real-estate-gaillardia-way-property": 1, "real-estate-law-101-ca-landlord-entry-laws-explained": 1, "ab1482-explained-avoid-landlord-mistakes": 1, "client-testimonial-kevin-rebecca-bowe": 1, "east-palo-alto-market-update-2022-inventory-and-prices": 1, "east-palo-alto-market-update-2022-sell-for-maximum-value": 1, "real-estate-market-update-city-vs-county-closing-costs": 1, "redwood-city-real-estate-2025-market-shocker-revealed": 1, "discover-stunning-homes-for-sale-east-palo-alto-ca-graeham-watts-advantage": 1, "east-palo-alto-market-update-2022-home-value-increase": 1, "fha-loans-vs-conventional-loans": 1, "Home-Selling-Tips-Must-Know": 1, "price-your-home-right": 1, "thinking-of-selling-watch-this": 1, "home-worth-estimate": 1, "bay-area-realtor-why-experience-matters": 1, "san-mateo-real-estate-update": 1, "redwood-city-real-estate-market-update": 1, "east-palo-alto-real-estate-mark-dinan-vision-for-change-city-council-candidate": 1, "tech-growth-impact-redwood-city-real-estate": 1, "interest-rates-and-real-estate-market-impact": 1, "bay-area-realtor-graeham-watts-difference": 1, "east-palo-alto-houses-14-robin-ct-tour": 1, "unlock-redwood-city-real-estate": 1, "real-estate-market-update-22277-hartman-drive-property-tour": 1, "homes-for-sale-redwood-city-ca-staging-secrets": 1, "mt-carmel-redwood-city-homes-for-sale": 1, "redwood-city-real-estate-market-update-graeham-watts": 1, "redwood-city-real-estate-hidden-gems-revealed": 1, "mortgage-costs-redwood-city-real-estate-ca": 1, "discover-affordable-homes-under-100k-across-america": 1, "redwood-city-ca-real-estate-property-tax": 1, "ab-1482-explained-landlords-tenants-must-know": 1, "homes-for-sale-in-redwood-city-ca": 1, "redwood-city-real-estate-monthly-market-update": 1, "real-estate-market-forecast-foreclosure-myth-busted": 1, "roosevelt-redwood-city-homes-for-sale": 1, "homes-for-sale-east-palo-alto-ca-living-guide": 1, "redwood-city-real-estate-market-update-feb-2025": 1, "redwood-city-real-estate-financing": 1, "essential-home-selling-tips": 1, "essential-home-selling-tips-fourth-step-offer-accepted": 1, "homes-for-sale-in-redwood-city-ca-590-hurlingame-ave-makeover": 1, "homes-for-sale-in-the-bay-area-3500-19th-st-san-francisco": 1, "homes-for-sale-in-east-palo-alto-ca-2288-addison-ave": 1, "homes-for-sale-in-redwood-city-ca-winning-offers-underwriting-edge": 1, "homes-for-sale-in-the-bay-area-786-honeywood-court-pending-sale": 1, "homes-for-sale-in-the-bay-area-graeham-watts-2022-successes": 1, "real-estate-market-update-186-overlook-ave-hayward-ca-virtual-tour": 1, "bay-area-realtor-graeham-watts-client-love": 1, "ab-1482-explained-maximum-rent-increase-limits-in-california": 1, "new-rent-rules-real-estate-predictions-2025-tenants-landlords": 0, "redwood-city-real-estate-moving-guide-what-you-need-to-know": 0, "east-menlo-park-market-update-february-2025": 0, "east-palo-alto-real-estate-moving-guide": 0, "home-selling-tips-make-sales-breeze-no-stress": 0, "east-palo-alto-homes-for-sale": 0, "S9z9": 0, "east-palo-alto-dream-home-tour-30-seconds": 0, "redwood-city-real-estate-hidden-secrets": 0, "real-estate-market-trends-2025-alameda-county-hidden-gem": 0, "unbelievable-house-tour-market-forecast": 0, "palm-redwood-city-homes-for-sale": 0, "discover-east-palo-alto-real-estate-february-market-update": 0, "homes-for-sale-in-the-bay-area-soquel-3-bedroom": 0, "exciting-real-estate-market-update-jumping-housing-market": 0, "trusted-bay-area-realtor-house-safety-guarantee": 0, "redwood-city-real-estate-floor-installation-tips": 0, "unexpected-homes-for-sale-bay-area-dream-home": 0, "property-tax-shock-2025-real-estate-forecast": 0, "explore-east-palo-alto-homes-graeham-watts": 0, "101_Graden_St": 0, "ravenswood-school-district-50m-investment-better-schools-higher-teacher-pay": 0, "top-tips-for-selling-homes-quickly-maximize-home-value": 0, "rising-interest-rates-and-real-estate-prices": 0, "homes-for-sale-east-palo-alto": 0, "buy-a-home-east-palo-alto": 0, "redwood-village-homes-for-sale-in-redwood-city": 0, "east-palo-alto-market-update-january-2025-key-insights": 0, "real-estate-market-update-hartman-drive-los-altos-ca-virtual-tour": 0, "bay-area-realtor-secrets-unveiled": 0, "east-palo-alto-next-real-estate-goldmine": 0, "east-palo-alto-ca-homes-for-sale-house-tour": 0, "homes-for-sale-menlo-park-ca-modern-home-tour-1318-hollyburne-avenue": 0, "graeham-watts-east-palo-alto-listings": 0, "real-estate-market-trends-2025-phone-to-realty": 0, "discover-homes-for-sale-east-palo-alto-ca-1560-kavanaugh-drive-tour": 0, "home-selling-tips-busting-3-buyer-myths": 0, "the-buzz-in-redwood-city-real-estate": 0, "ab1482-what-landlords-need-to-know-and-do": 0, "eagle-hill-redwood-city-homes-for-sale": 0, "east-menlo-park-homes-for-sale-graeham-watts": 0, "epa-comps-0601": 0, "discover-stunning-homes-for-sale-menlo-park": 0, "east-menlo-park-homes-for-sale-listings-graeham-watts": 0, "expert-home-selling-tips": 0, "bay-area-realtor-free-staging-and-repairs": 0, "ab-1482-rent-increases-exemptions-and-california-rental-laws-explained": 0, "Ace_Your_First_Home_Purchase": 0, "homes-for-sale-menlo-park-ca-update": 0, "bay-area-home-search-map": 0, "downtown-redwood-city-homes-for-sale": 0, "maximize-your-home-sale-value": 0, "east-menlo-park-homes-for-sale": 0, "redwood-city-real-estate-paradise": 0, "Redwood-Oaks-homes-for-sale-in-redwood-city-ca": 0, "stambaugh-heller-redwood-city-homes-for-sale": 0, "east-palo-alto-real-estate-housing-market-update": 0, "essential-real-estate-market-update-homebuyers-guide": 0, "redwood-city-real-estate-housing-market-update": 0, "real-estate-market-update-2025-california-landlord-risks": 0, "central-redwood-city-homes-for-sale": 0, "east-palo-alto-homes-for-sale-woodland-creek-condo": 0, "discovering-redwood-city-real-estate": 0, "menlo-park-market-update-belle-haven-housing-trends": 0, "real-estate-market-forecast-fed-interest-rates-explained": 0, "redwood-city-real-estate-crucial-market-update": 0, "real-estate-market-update-just-listed-1186-overlook-ave-hayward-ca-house-tour": 0, "east-palo-alto-real-estate-monthly-market-update": 0, "centennial-redwood-city-homes-for-sale": 0, "real-estate-market-trends-2025": 0, "kIhN": 0, "best-indian-restaurant-redwood-city-broadway-masala": 0, "Redwood-City-Homes-on-Sale-Hot-Listings-Price-Drops": 0, "Redwood-City-Homes-on-Sale-Hot-Listings-Price-Drops-Graeham-Watts": 0, "Redwood-City-Homes-on-Sale": 0, "2115-Clarke-Ave": 0, "Redwood-City-Homes-on-Sale-Hot-Listings": 0, "redwood-city-real-estate-san-francisco-monthly-market-update": 0, "essential-real-estate-market-update-guide-for-homebuyers": 0, "essential-home-selling-tips-second-step-pre-qualification": 0, "choose-the-right-agent": 0, "ab-1482-explained-is-your-property-rent-controlled-or-exempt": 0, "stunning-homes-for-sale-menlo-park-1135-madera-ave-tour": 0, "redwood-city-spanish-style-4-bedroom-friendly-acres": 0, "east-palo-alto-real-estate-market-update": 0, "real-estate-market-update-foundation-tips-when-buying-your-new-home": 0, "real-estate-market-update-tips-to-protect-your-offer-and-investment": 0, "east-palo-alto-real-estate-mark-dinan-vision-change": 0, "what-you-can-buy-redwood-city-houses-for-sale": 0, "redwood-city-real-estate-market-february-update-you-need-to-know": 0, "selling-your-home-just-got-easier": 0}} \ No newline at end of file diff --git a/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-05-29.json b/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-05-29.json new file mode 100644 index 0000000..38635ff --- /dev/null +++ b/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-05-29.json @@ -0,0 +1 @@ +{"date": "2026-05-29", "clicks": {"bay-area-homes-for-sale": 1067, "SbSR": 1023, "graehamwatts-meet": 776, "whats-my-house-worth": 722, "SKZY": 681, "SKaA": 602, "SK0r": 586, "XhI5": 570, "SK1P": 547, "SKai": 515, "SKY8": 509, "redwood-city-homes-for-sale": 496, "SKYl": 460, "east-palo-alto-ca-homes-for-sale": 430, "homes-for-sale-belle-haven": 397, "T1td": 396, "SJob": 376, "SJ-k": 296, "MFUy": 282, "SXDB": 234, "SM9A": 205, "SKZH": 190, "SRlP": 185, "SKDr": 180, "SX1b": 170, "SJoH": 163, "SKYx": 141, "SJpt": 141, "east-palo-alto-CA-homes-for-sale-under-1million": 136, "SJ-D": 126, "SX7L": 124, "SK0T": 123, "S-VQ": 116, "T1nG": 111, "SXGz": 108, "SKYW": 105, "East-palo-alto-home-for-sale": 96, "menlo-park-homes-for-sale": 94, "how-much-is-my-home-worth": 93, "T1r5": 90, "S-Vz": 88, "redwood-city-home-for-sale": 80, "T8PU": 78, "TA7m": 71, "SXG2": 70, "SX5l": 70, "S9yj": 67, "XhH8": 61, "SKYI": 60, "schedule-bay-area-real-estate-meeting": 60, "blog-east-palo-alto-ca-homes-for-sale": 59, "free-home-evaluation": 59, "S-Vg": 59, "S9yo": 54, "T1v5": 51, "SM92": 50, "SXBD": 48, "T8Yl": 41, "SX4-": 41, "S9xP": 41, "SKXy": 39, "redwood-city-ca-homes-for-sale": 33, "real-estate-market-forecast-save-thousands-on-your-mortgage-hack": 28, "WVkH": 28, "formly_email": 28, "bay-area-real-estate-market-update-2026-01": 27, "S9w-": 27, "SKXW": 24, "Meet-with-Graeham": 22, "S9xr": 22, "SM9K": 20, "Home-For-Sale": 20, "sell-with-graeham": 19, "east-menlo-park": 19, "5636-orchard-park-drive-san-jose-ca-95123": 17, "why-sell-with-graeham": 16, "discover-east-menlo-park-homes": 15, "Belle-haven-home-value-evaluation": 14, "SKaM": 13, "east-palo-alto-real-estate-sold-40k-more-with-50k-issue": 13, "homes-for-sale-redwood-city-ca": 13, "get_free_consultation": 13, "home-selling-tips-sell-your-home-quickly": 12, "homevalue": 11, "v4Sk": 10, "S9y0": 10, "before_after_1930_Sarah_Dr": 9, "SX9V": 9, "S9z0": 9, "schedule-call-with-graeham": 9, "woodside-plaza-redwood-city-homes-for-sale": 9, "east-palo-alto-condo-1982-w-bayshore-223": 9, "essential-home-selling-tips-5-Biggest-Mistakes": 8, "homes-for-sale-east-palo-alto-ca-discover-your-dream-home": 7, "952-6th-AVE-Redwood-City-CA-94063": 7, "828-Weeks-ST": 7, "homes-for-sale-in-the-bay-area-safety-tips": 7, "free-home-valuation": 7, "Home-Buying-Myths": 7, "redwood-city-real-estate-expert-advice-you-can-trust": 6, "east-palo-alto-real-estate-470-bell-st-tranquil-home-tour-silicon-valley": 6, "real-estate-market-forecast-fed-rate-cuts-2025-housing-market": 6, "bay-area-realtor-graeham-watts-winnie-danny-dream-home": 6, "real-estate-market-update-transformed-sold-for-top-dollar": 6, "east-palo-alto-condos-for-sale-woodland-creek-223": 5, "3-Buyer-Myths": 5, "selling-homes-quickly-tips-maximize-home-value": 5, "east-palo-alto-real-estate-insights-650k-fixer-upper-dream-home": 5, "Home-Evaluation": 5, "bay-area-realtor-dream-home-client-testimonial": 5, "2025-california-landlord-risk-update": 5, "Why-Clients-Trust-Graeham-Watts": 5, "homes-for-sale-in-menlo-park-graeham-watts-tour": 5, "redwood-city-real-estate-cost-of-living": 4, "Proven-Home-Selling-Tips": 4, "east-palo-alto-homes-for-sale-431-larkspur-dr-record-sale": 4, "east-palo-alto-market-update-prices-rising-homes-selling-fast": 4, "KII7": 4, "east-palo-alto-real-estate-470-bell-st-listing": 4, "avoid-costly-homebuyer-errors-redwood-city-real-estate-ca": 4, "SJpA": 4, "real-estate-market-update-313-smithwood-milpitas-ca-95035-home-tour": 4, "Offer-Accepted": 4, "top-east-palo-alto-realtor-sell-home-fast-top-dollar": 4, "top-east-palo-alto-realtor-home-selling-tips": 4, "discover-homes-for-sale-redwood-city-ca-spanish-style-4-bedroom-friendly-acres": 4, "san-jose-home-for-sale-500k-assumable-rate": 4, "How-We-Price-Your-Home": 4, "east-palo-alto-homes-for-sale-123-main-st-tour": 4, "redwood-city-real-estate-monthly-market-update-Graeham-watts": 4, "houses-in-east-palo-alto-modernized-1239-jervis-ave": 4, "East-Palo-Alto-Real-Estate-Monthly-Market-Update-graeham-watts": 4, "east-palo-alto-real-estate-missed-ca-fee-amnesty-act-now": 3, "east-palo-alto-realtor-sell-home-fast-top-dollar": 3, "bay-area-realtor-graeham-watts-testimonial-savings": 3, "1457-quail-st-los-banos-4-bedroom-home": 3, "affordable-east-palo-alto-homes-14-robin-court-tour": 3, "bay-area-realtor-free-home-staging-maximize-value": 3, "real-estate-predictions-2025-east-menlo-park": 3, "redwood-city-real-estate-graeham-watts-love-this-city": 3, "bay-area-realtor-california-rent-rules": 3, "bay-area-realtor-supply-and-demand-toilet-paper-bidding-war": 3, "home-selling-tips-maximize-sale-price-fast": 3, "big-head-big-ideas-big-results-real-estate-predictions-2025": 3, "bay-area-realtor-graeham-watts-johnny-ashley-success": 3, "ca-rent-laws-in-crises": 3, "east-palo-alto-real-estate-market-update-graeham-watts": 3, "essential-home-selling-tips-third-step-home-search": 3, "bay-area-realtor-graeham-watts-ryan-teeda-journey": 3, "1908-Cooley-AVE": 3, "east-menlo-park-market-update-prices-up-inventory-low": 3, "bair-island-homes-for-sale-in-redwood-city": 3, "east-menlo-park-real-estate-march-market-update": 3, "The-Graeham-Watts-Advantage": 3, "homes-for-sale-in-the-bay-area-buy-sell-with-graeham-watts": 3, "redwood-city-march-real-estate-market-update": 3, "discover-east-palo-alto-real-estate-stunning-home-under-900k": 3, "redwood-city-homes-for-sale-hidden-gems-affordable-living": 3, "redwood-city-real-estate-off-market-secrets": 3, "real-estate-market-forecast": 3, "east-palo-alto-market-update-are-you-keeping-up": 3, "east-palo-alto-ravenswood-school-district-transformation": 3, "east-palo-alto-ca-homes-for-sale-woodland-creek-condo-tour": 3, "redwood-city-real-estate-insider": 3, "client-testimonial-moneisha-jermell": 3, "bay-area-realtor-graeham-watts-hidden-gems": 3, "bay-area-realtor-graeham-watts-dream-home-success": 3, "east-palo-alto-ca-house-for-sale-worth-investment": 2, "real-estate-market-update-homes-selling-over-asking": 2, "homes-for-sale-in-east-palo-alto-ca-431-larkspur-dr": 2, "east-palo-alto-real-estate-why-now-is-the-time-to-buy": 2, "belle-haven-market-update": 2, "unleashing-potential-east-palo-alto-real-estate-transformative-fixer-upper": 2, "homes-for-sale-east-palo-alto-ca-117-mission-drive": 2, "bay-area-realtor-graeham-watts-record-home-sale": 2, "redwood-city-homes-for-sale-757-douglas-ave-tour": 2, "homes-for-sale-in-menlo-park-home-value": 2, "east-palo-alto-homes-for-sale-2620-fordham-st-update": 2, "friendly-acres-redwood-city": 2, "redwood-city-condos-for-sale": 2, "redwood-city-living-expense": 2, "east-menlo-park-market-update": 2, "max-profit-selling-homes-redwood-city": 2, "graeham-watts-advantage-redwood-city-real-estate": 2, "east-palo-alto-ca-homes-for-sale-2109-myrtle-pl-tour": 2, "redwood-shores-homes-for-sale-luxury-waterfront-tour": 2, "menlo-park-houses-for-sale-breathtaking-transformation": 2, "houses-for-sale-in-east-palo-alto-1404-camellia-drive": 2, "houses-in-east-palo-alto-952-newbridge-st-real-estate-gem": 2, "bay-area-realtor-graeham-client-testimonial-john-ward": 2, "home-selling-tips-attract-perfect-buyers": 2, "real-estate-market-update-menlo-park-2022-trends": 2, "bay-area-realtor-graeham-watts-trusted-expert": 2, "home-selling-tips-get-top-dollar-for-your-property": 2, "homes-for-sale-in-the-bay-area-los-gatos-home-sells-big": 2, "homes-for-sale-in-the-bay-area-luxury-the-westerly": 2, "redwood-city-real-estate-ca-safer": 2, "redwood-city-real-estate-next-big-investment": 2, "homes-for-sale-in-menlo-park-market-trends-graeham-watts": 2, "bay-area-realtor-home-alone-reaction": 2, "redwood-city-real-estate-january-market-update": 2, "east-menlo-park-market-update-january-2025": 2, "east-palo-alto-real-estate-homeowner-update": 2, "redwood-city-real-estate-charming-dream-homes": 2, "redwood-city-march-real-estate-market-update-2025": 2, "redwood-city-real-estate-market-home-value-insight": 2, "selling-homes-quickly-tips-kitchen-countertops": 2, "SK1C": 2, "SM9O": 2, "east-palo-alto-ca-homes-for-sale-1765-e-bayshore-rd-214": 2, "home-selling-tips-expert-staging-top-dollar": 2, "bay-area-realtor-essential-real-estate-tips": 2, "Home-Search": 2, "redwood-city-real-estate-prices": 2, "east-palo-alto-california-real-estate": 2, "rise-and-fall-of-silicon-valley-bank": 2, "bay-area-realtor-graeham-watts-off-market-dream-homes": 2, "just-sold-east-palo-alto-real-estate-1765-e-bayshore-rd-203": 2, "real-estate-market-update-stunning-home-oakley-59-escher-circle-tour-features": 2, "broadway-masala-redwood-city-indian-food": 2, "east-bay-real-estate-hidden-deals-maximize-sale": 2, "join-bay-area-realtor-graeham-watts-fiesta": 2, "mortgage-rates-forecast-2025-housing-loan-trends": 2, "shocking-bay-area-house-tour-twist": 2, "iBiB": 2, "bay-area-realtor-graeham-watts-humor-and-real-estate": 2, "bay-area-realtor-client-testimonial-trust": 2, "bay-area-realtor-daily-hustle-inside-look": 2, "graeham-watts-client-testimonials": 2, "Choosing-a-Bay-Area-Realtor": 2, "Understanding-Contingencies": 2, "nEnF": 2, "is-now-the-best-time-to-buy-real-estate": 2, "v4S8": 2, "sell-home-prep-tips": 2, "real-estate-market-update-you-need-to-know": 2, "Home-Selling-Tips": 2, "redwood-city-real-estate-ca-silicon-valley-oasis": 2, "graeham-watts-bay-area-realtor-real-estate-success": 2, "bay-area-realtor-graeham-watts-advantage": 2, "houses-for-sale-east-palo-alto-453-okeefe-st": 1, "east-menlo-park-market-update-home-prices-up": 1, "Real-Estate-Market-Forecast-Save-Thousands": 1, "real-estate-market-forecast-august-trends": 1, "bay-area-realtor-success-story-marketing-results": 1, "Why-Clients-Trust-Graeham": 1, "redwood-city-real-estate-charming-2bd-1ba-home-tour-expansive-yard": 1, "real-estate-market-update-top-investment-websites": 1, "east-palo-alto-market-update-free-home-valuation": 1, "east-palo-alto-homes-for-sale-2620-fordham": 1, "belle-haven-market-update-menlo-park": 1, "real-estate-market-update-sell-home-top-dollar": 1, "east-palo-alto-real-estate-market-update-2023": 1, "homes-for-sale-in-the-bay-area-just-listed-hot-properties": 1, "bay-area-realtor-graeham-watts-staging-and-3d-tours": 1, "rising-interest-rates-and-real-estate": 1, "redwood-city-real-estate-update-sell-high-inventory-rising": 1, "redwood-city-real-estate-update-2022-home-value": 1, "Home-buyer-guide": 1, "houses-in-east-palo-alto-dream-homes-for-every-budget": 1, "menlo-park-houses-for-sale-monthly-market-update": 1, "east-palo-alto-homes-for-sale-stylish-loft": 1, "ULo7": 1, "redwood-city-real-estate-market-update-for-homeowners": 1, "east-palo-alto-california-real-estate-trends": 1, "real-estate-market-trends-2025-home-warranty-explained": 1, "real-estate-inventory-trends-low-inventory-market-impact": 1, "east-palo-alto-real-estate-market-update-feb-2025": 1, "homes-for-sale-in-the-bay-area-san-leandro-modern-family-home": 1, "bay-area-realtor-graeham-watts-client-testimonial": 1, "homes-for-sale-in-farm-hill-7210-eagle-ridge-dr-gilroy-ca": 1, "KII1": 1, "essential-home-selling-tips-biggest-seller-mistakes": 1, "east-palo-alto-ca-homes-for-sale-buyer-tips-1765-e-bayshore-rd-204": 1, "menlo-park-real-estate-market-update-latest-trends-insights": 1, "home-selling-tips-fix-now-pay-later": 1, "homes-for-sale-in-redwood-city": 1, "real-estate-market-forecast-shocker-haunting-2025": 1, "home-selling-tips-out-of-state-overwhelmed-help": 1, "real-estate-update-home-selling-fast": 1, "rare-redwood-city-real-estate-fixer-2-bed-condo-hidden-potential": 1, "redwood-city-real-estate-insights": 1, "game-changing-real-estate-predictions-2025-big-deals-big-results": 1, "home-selling-mistakes-to-avoid": 1, "best-time-to-buy-real-estate-1930-pinole-drive-tour": 1, "ab-1482-unlocked-secrets-every-california-landlord-should-know": 1, "scariest-real-estate-costume-ever": 1, "redwood-city-real-estate-market-update-prices-up-inventory-down": 1, "home-alone-sequel-we-always-wanted-east-palo-alto-homes": 1, "unlock-hidden-redwood-city-real-estate-ca-gems": 1, "beat-the-market-east-palo-alto-market-update-secret-price-bump": 1, "Featured-Property-menlo-park": 1, "redwood-city-real-estate-renovation-alert": 1, "2842-Cornelius-Dr-Tour": 1, "Featured-Property-Redwood-City": 1, "Featured-Property-east-palo-alto": 1, "east-palo-alto-homes-for-sale-1765-bayshore-rd-203": 1, "homes-for-sale-in-east-palo-alto-ca-2398-palgas-ave": 1, "redwood-city-real-estate-2022-sell-high-find-home": 1, "redwood-city-real-estate-showdown": 1, "east-palo-alto-market-update-2022-inventory-and-prices": 1, "east-palo-alto-real-estate-gaillardia-way-property": 1, "real-estate-law-101-ca-landlord-entry-laws-explained": 1, "ab1482-explained-avoid-landlord-mistakes": 1, "client-testimonial-kevin-rebecca-bowe": 1, "east-palo-alto-market-update-2022-sell-for-maximum-value": 1, "real-estate-market-update-city-vs-county-closing-costs": 1, "redwood-city-real-estate-2025-market-shocker-revealed": 1, "discover-stunning-homes-for-sale-east-palo-alto-ca-graeham-watts-advantage": 1, "east-palo-alto-market-update-2022-home-value-increase": 1, "fha-loans-vs-conventional-loans": 1, "Home-Selling-Tips-Must-Know": 1, "price-your-home-right": 1, "thinking-of-selling-watch-this": 1, "home-worth-estimate": 1, "bay-area-realtor-why-experience-matters": 1, "san-mateo-real-estate-update": 1, "redwood-city-real-estate-market-update": 1, "east-palo-alto-real-estate-mark-dinan-vision-for-change-city-council-candidate": 1, "tech-growth-impact-redwood-city-real-estate": 1, "interest-rates-and-real-estate-market-impact": 1, "bay-area-realtor-graeham-watts-difference": 1, "east-palo-alto-houses-14-robin-ct-tour": 1, "unlock-redwood-city-real-estate": 1, "real-estate-market-update-22277-hartman-drive-property-tour": 1, "homes-for-sale-redwood-city-ca-staging-secrets": 1, "mt-carmel-redwood-city-homes-for-sale": 1, "redwood-city-real-estate-market-update-graeham-watts": 1, "redwood-city-real-estate-hidden-gems-revealed": 1, "mortgage-costs-redwood-city-real-estate-ca": 1, "discover-affordable-homes-under-100k-across-america": 1, "redwood-city-ca-real-estate-property-tax": 1, "ab-1482-explained-landlords-tenants-must-know": 1, "homes-for-sale-in-redwood-city-ca": 1, "redwood-city-real-estate-monthly-market-update": 1, "real-estate-market-forecast-foreclosure-myth-busted": 1, "roosevelt-redwood-city-homes-for-sale": 1, "homes-for-sale-east-palo-alto-ca-living-guide": 1, "redwood-city-real-estate-market-update-feb-2025": 1, "redwood-city-real-estate-financing": 1, "essential-home-selling-tips": 1, "essential-home-selling-tips-fourth-step-offer-accepted": 1, "homes-for-sale-in-redwood-city-ca-590-hurlingame-ave-makeover": 1, "homes-for-sale-in-the-bay-area-3500-19th-st-san-francisco": 1, "homes-for-sale-in-east-palo-alto-ca-2288-addison-ave": 1, "homes-for-sale-in-redwood-city-ca-winning-offers-underwriting-edge": 1, "homes-for-sale-in-the-bay-area-786-honeywood-court-pending-sale": 1, "homes-for-sale-in-the-bay-area-graeham-watts-2022-successes": 1, "real-estate-market-update-186-overlook-ave-hayward-ca-virtual-tour": 1, "bay-area-realtor-graeham-watts-client-love": 1, "ab-1482-explained-maximum-rent-increase-limits-in-california": 1, "new-rent-rules-real-estate-predictions-2025-tenants-landlords": 0, "redwood-city-real-estate-moving-guide-what-you-need-to-know": 0, "east-menlo-park-market-update-february-2025": 0, "east-palo-alto-real-estate-moving-guide": 0, "home-selling-tips-make-sales-breeze-no-stress": 0, "east-palo-alto-homes-for-sale": 0, "S9z9": 0, "east-palo-alto-dream-home-tour-30-seconds": 0, "redwood-city-real-estate-hidden-secrets": 0, "real-estate-market-trends-2025-alameda-county-hidden-gem": 0, "unbelievable-house-tour-market-forecast": 0, "palm-redwood-city-homes-for-sale": 0, "discover-east-palo-alto-real-estate-february-market-update": 0, "homes-for-sale-in-the-bay-area-soquel-3-bedroom": 0, "exciting-real-estate-market-update-jumping-housing-market": 0, "trusted-bay-area-realtor-house-safety-guarantee": 0, "redwood-city-real-estate-floor-installation-tips": 0, "unexpected-homes-for-sale-bay-area-dream-home": 0, "property-tax-shock-2025-real-estate-forecast": 0, "explore-east-palo-alto-homes-graeham-watts": 0, "101_Graden_St": 0, "ravenswood-school-district-50m-investment-better-schools-higher-teacher-pay": 0, "top-tips-for-selling-homes-quickly-maximize-home-value": 0, "rising-interest-rates-and-real-estate-prices": 0, "homes-for-sale-east-palo-alto": 0, "buy-a-home-east-palo-alto": 0, "redwood-village-homes-for-sale-in-redwood-city": 0, "east-palo-alto-market-update-january-2025-key-insights": 0, "real-estate-market-update-hartman-drive-los-altos-ca-virtual-tour": 0, "bay-area-realtor-secrets-unveiled": 0, "east-palo-alto-next-real-estate-goldmine": 0, "east-palo-alto-ca-homes-for-sale-house-tour": 0, "homes-for-sale-menlo-park-ca-modern-home-tour-1318-hollyburne-avenue": 0, "graeham-watts-east-palo-alto-listings": 0, "real-estate-market-trends-2025-phone-to-realty": 0, "discover-homes-for-sale-east-palo-alto-ca-1560-kavanaugh-drive-tour": 0, "home-selling-tips-busting-3-buyer-myths": 0, "the-buzz-in-redwood-city-real-estate": 0, "epa-comps-0601": 0, "ab1482-what-landlords-need-to-know-and-do": 0, "eagle-hill-redwood-city-homes-for-sale": 0, "east-menlo-park-homes-for-sale-graeham-watts": 0, "discover-stunning-homes-for-sale-menlo-park": 0, "east-menlo-park-homes-for-sale-listings-graeham-watts": 0, "expert-home-selling-tips": 0, "bay-area-realtor-free-staging-and-repairs": 0, "bay-area-home-search-map": 0, "Ace_Your_First_Home_Purchase": 0, "homes-for-sale-menlo-park-ca-update": 0, "downtown-redwood-city-homes-for-sale": 0, "maximize-your-home-sale-value": 0, "east-menlo-park-homes-for-sale": 0, "redwood-city-real-estate-paradise": 0, "stambaugh-heller-redwood-city-homes-for-sale": 0, "Redwood-Oaks-homes-for-sale-in-redwood-city-ca": 0, "real-estate-market-update-2025-california-landlord-risks": 0, "east-palo-alto-real-estate-housing-market-update": 0, "essential-real-estate-market-update-homebuyers-guide": 0, "redwood-city-real-estate-housing-market-update": 0, "central-redwood-city-homes-for-sale": 0, "east-palo-alto-homes-for-sale-woodland-creek-condo": 0, "discovering-redwood-city-real-estate": 0, "redwood-city-real-estate-crucial-market-update": 0, "menlo-park-market-update-belle-haven-housing-trends": 0, "real-estate-market-forecast-fed-interest-rates-explained": 0, "real-estate-market-update-just-listed-1186-overlook-ave-hayward-ca-house-tour": 0, "centennial-redwood-city-homes-for-sale": 0, "east-palo-alto-real-estate-monthly-market-update": 0, "kIhN": 0, "real-estate-market-trends-2025": 0, "best-indian-restaurant-redwood-city-broadway-masala": 0, "Redwood-City-Homes-on-Sale-Hot-Listings-Price-Drops": 0, "ab-1482-rent-increases-exemptions-and-california-rental-laws-explained": 0, "Redwood-City-Homes-on-Sale-Hot-Listings-Price-Drops-Graeham-Watts": 0, "Redwood-City-Homes-on-Sale": 0, "2115-Clarke-Ave": 0, "Redwood-City-Homes-on-Sale-Hot-Listings": 0, "redwood-city-real-estate-san-francisco-monthly-market-update": 0, "essential-real-estate-market-update-guide-for-homebuyers": 0, "essential-home-selling-tips-second-step-pre-qualification": 0, "choose-the-right-agent": 0, "ab-1482-explained-is-your-property-rent-controlled-or-exempt": 0, "stunning-homes-for-sale-menlo-park-1135-madera-ave-tour": 0, "redwood-city-spanish-style-4-bedroom-friendly-acres": 0, "east-palo-alto-real-estate-market-update": 0, "real-estate-market-update-foundation-tips-when-buying-your-new-home": 0, "real-estate-market-update-tips-to-protect-your-offer-and-investment": 0, "east-palo-alto-real-estate-mark-dinan-vision-change": 0, "what-you-can-buy-redwood-city-houses-for-sale": 0, "redwood-city-real-estate-market-february-update-you-need-to-know": 0, "selling-your-home-just-got-easier": 0}} \ No newline at end of file diff --git a/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-06-01.json b/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-06-01.json new file mode 100644 index 0000000..ec3e288 --- /dev/null +++ b/online-content/dashboards/switchy/snapshots/switchy-snapshot-2026-06-01.json @@ -0,0 +1 @@ +{"date": "2026-06-01", "clicks": {"bay-area-homes-for-sale": 1069, "SbSR": 1023, "graehamwatts-meet": 779, "whats-my-house-worth": 722, "SKZY": 681, "SKaA": 602, "SK0r": 586, "XhI5": 570, "SK1P": 547, "SKai": 515, "SKY8": 509, "redwood-city-homes-for-sale": 497, "SKYl": 460, "east-palo-alto-ca-homes-for-sale": 430, "homes-for-sale-belle-haven": 397, "T1td": 396, "SJob": 376, "SJ-k": 296, "MFUy": 282, "SXDB": 234, "SM9A": 205, "SKZH": 190, "SRlP": 185, "SKDr": 180, "SX1b": 170, "SJoH": 163, "SJpt": 141, "SKYx": 141, "east-palo-alto-CA-homes-for-sale-under-1million": 136, "SJ-D": 126, "SX7L": 124, "SK0T": 123, "S-VQ": 116, "T1nG": 111, "SXGz": 108, "SKYW": 105, "East-palo-alto-home-for-sale": 96, "menlo-park-homes-for-sale": 94, "how-much-is-my-home-worth": 93, "T1r5": 90, "S-Vz": 88, "redwood-city-home-for-sale": 80, "T8PU": 78, "TA7m": 71, "SX5l": 70, "SXG2": 70, "S9yj": 67, "XhH8": 61, "SKYI": 60, "schedule-bay-area-real-estate-meeting": 60, "blog-east-palo-alto-ca-homes-for-sale": 59, "free-home-evaluation": 59, "S-Vg": 59, "S9yo": 54, "T1v5": 51, "SM92": 50, "SXBD": 48, "T8Yl": 41, "SX4-": 41, "S9xP": 41, "SKXy": 39, "redwood-city-ca-homes-for-sale": 33, "real-estate-market-forecast-save-thousands-on-your-mortgage-hack": 28, "WVkH": 28, "formly_email": 28, "bay-area-real-estate-market-update-2026-01": 27, "S9w-": 27, "SKXW": 24, "Meet-with-Graeham": 22, "S9xr": 22, "SM9K": 20, "Home-For-Sale": 20, "sell-with-graeham": 19, "east-menlo-park": 19, "5636-orchard-park-drive-san-jose-ca-95123": 17, "why-sell-with-graeham": 16, "discover-east-menlo-park-homes": 15, "Belle-haven-home-value-evaluation": 14, "SKaM": 13, "east-palo-alto-real-estate-sold-40k-more-with-50k-issue": 13, "homes-for-sale-redwood-city-ca": 13, "get_free_consultation": 13, "home-selling-tips-sell-your-home-quickly": 12, "homevalue": 11, "v4Sk": 10, "S9y0": 10, "before_after_1930_Sarah_Dr": 9, "SX9V": 9, "S9z0": 9, "schedule-call-with-graeham": 9, "woodside-plaza-redwood-city-homes-for-sale": 9, "east-palo-alto-condo-1982-w-bayshore-223": 9, "essential-home-selling-tips-5-Biggest-Mistakes": 8, "homes-for-sale-east-palo-alto-ca-discover-your-dream-home": 7, "952-6th-AVE-Redwood-City-CA-94063": 7, "828-Weeks-ST": 7, "homes-for-sale-in-the-bay-area-safety-tips": 7, "free-home-valuation": 7, "Home-Buying-Myths": 7, "redwood-city-real-estate-expert-advice-you-can-trust": 6, "east-palo-alto-real-estate-470-bell-st-tranquil-home-tour-silicon-valley": 6, "real-estate-market-forecast-fed-rate-cuts-2025-housing-market": 6, "bay-area-realtor-graeham-watts-winnie-danny-dream-home": 6, "real-estate-market-update-transformed-sold-for-top-dollar": 6, "east-palo-alto-condos-for-sale-woodland-creek-223": 5, "3-Buyer-Myths": 5, "selling-homes-quickly-tips-maximize-home-value": 5, "east-palo-alto-real-estate-insights-650k-fixer-upper-dream-home": 5, "Home-Evaluation": 5, "bay-area-realtor-dream-home-client-testimonial": 5, "2025-california-landlord-risk-update": 5, "Why-Clients-Trust-Graeham-Watts": 5, "homes-for-sale-in-menlo-park-graeham-watts-tour": 5, "redwood-city-real-estate-cost-of-living": 4, "Proven-Home-Selling-Tips": 4, "east-palo-alto-homes-for-sale-431-larkspur-dr-record-sale": 4, "east-palo-alto-market-update-prices-rising-homes-selling-fast": 4, "KII7": 4, "east-palo-alto-real-estate-470-bell-st-listing": 4, "avoid-costly-homebuyer-errors-redwood-city-real-estate-ca": 4, "SJpA": 4, "real-estate-market-update-313-smithwood-milpitas-ca-95035-home-tour": 4, "Offer-Accepted": 4, "top-east-palo-alto-realtor-sell-home-fast-top-dollar": 4, "top-east-palo-alto-realtor-home-selling-tips": 4, "discover-homes-for-sale-redwood-city-ca-spanish-style-4-bedroom-friendly-acres": 4, "san-jose-home-for-sale-500k-assumable-rate": 4, "How-We-Price-Your-Home": 4, "east-palo-alto-homes-for-sale-123-main-st-tour": 4, "redwood-city-real-estate-monthly-market-update-Graeham-watts": 4, "houses-in-east-palo-alto-modernized-1239-jervis-ave": 4, "East-Palo-Alto-Real-Estate-Monthly-Market-Update-graeham-watts": 4, "east-palo-alto-real-estate-missed-ca-fee-amnesty-act-now": 3, "east-palo-alto-realtor-sell-home-fast-top-dollar": 3, "bay-area-realtor-graeham-watts-testimonial-savings": 3, "1457-quail-st-los-banos-4-bedroom-home": 3, "affordable-east-palo-alto-homes-14-robin-court-tour": 3, "bay-area-realtor-free-home-staging-maximize-value": 3, "real-estate-predictions-2025-east-menlo-park": 3, "redwood-city-real-estate-graeham-watts-love-this-city": 3, "bay-area-realtor-california-rent-rules": 3, "bay-area-realtor-supply-and-demand-toilet-paper-bidding-war": 3, "home-selling-tips-maximize-sale-price-fast": 3, "big-head-big-ideas-big-results-real-estate-predictions-2025": 3, "bay-area-realtor-graeham-watts-johnny-ashley-success": 3, "ca-rent-laws-in-crises": 3, "east-palo-alto-real-estate-market-update-graeham-watts": 3, "essential-home-selling-tips-third-step-home-search": 3, "bay-area-realtor-graeham-watts-ryan-teeda-journey": 3, "1908-Cooley-AVE": 3, "east-menlo-park-market-update-prices-up-inventory-low": 3, "bair-island-homes-for-sale-in-redwood-city": 3, "east-menlo-park-real-estate-march-market-update": 3, "The-Graeham-Watts-Advantage": 3, "homes-for-sale-in-the-bay-area-buy-sell-with-graeham-watts": 3, "redwood-city-march-real-estate-market-update": 3, "discover-east-palo-alto-real-estate-stunning-home-under-900k": 3, "redwood-city-homes-for-sale-hidden-gems-affordable-living": 3, "redwood-city-real-estate-off-market-secrets": 3, "real-estate-market-forecast": 3, "east-palo-alto-market-update-are-you-keeping-up": 3, "east-palo-alto-ravenswood-school-district-transformation": 3, "east-palo-alto-ca-homes-for-sale-woodland-creek-condo-tour": 3, "redwood-city-real-estate-insider": 3, "client-testimonial-moneisha-jermell": 3, "bay-area-realtor-graeham-watts-hidden-gems": 3, "bay-area-realtor-graeham-watts-dream-home-success": 3, "east-palo-alto-ca-house-for-sale-worth-investment": 2, "real-estate-market-update-homes-selling-over-asking": 2, "homes-for-sale-in-east-palo-alto-ca-431-larkspur-dr": 2, "east-palo-alto-real-estate-why-now-is-the-time-to-buy": 2, "belle-haven-market-update": 2, "unleashing-potential-east-palo-alto-real-estate-transformative-fixer-upper": 2, "homes-for-sale-east-palo-alto-ca-117-mission-drive": 2, "bay-area-realtor-graeham-watts-record-home-sale": 2, "redwood-city-homes-for-sale-757-douglas-ave-tour": 2, "homes-for-sale-in-menlo-park-home-value": 2, "east-palo-alto-homes-for-sale-2620-fordham-st-update": 2, "friendly-acres-redwood-city": 2, "redwood-city-condos-for-sale": 2, "redwood-city-living-expense": 2, "east-menlo-park-market-update": 2, "max-profit-selling-homes-redwood-city": 2, "graeham-watts-advantage-redwood-city-real-estate": 2, "east-palo-alto-ca-homes-for-sale-2109-myrtle-pl-tour": 2, "redwood-shores-homes-for-sale-luxury-waterfront-tour": 2, "menlo-park-houses-for-sale-breathtaking-transformation": 2, "houses-for-sale-in-east-palo-alto-1404-camellia-drive": 2, "houses-in-east-palo-alto-952-newbridge-st-real-estate-gem": 2, "bay-area-realtor-graeham-client-testimonial-john-ward": 2, "home-selling-tips-attract-perfect-buyers": 2, "real-estate-market-update-menlo-park-2022-trends": 2, "bay-area-realtor-graeham-watts-trusted-expert": 2, "home-selling-tips-get-top-dollar-for-your-property": 2, "homes-for-sale-in-the-bay-area-los-gatos-home-sells-big": 2, "homes-for-sale-in-the-bay-area-luxury-the-westerly": 2, "redwood-city-real-estate-ca-safer": 2, "redwood-city-real-estate-next-big-investment": 2, "homes-for-sale-in-menlo-park-market-trends-graeham-watts": 2, "bay-area-realtor-home-alone-reaction": 2, "redwood-city-real-estate-january-market-update": 2, "east-menlo-park-market-update-january-2025": 2, "east-palo-alto-real-estate-homeowner-update": 2, "redwood-city-real-estate-charming-dream-homes": 2, "redwood-city-march-real-estate-market-update-2025": 2, "redwood-city-real-estate-market-home-value-insight": 2, "selling-homes-quickly-tips-kitchen-countertops": 2, "SK1C": 2, "SM9O": 2, "east-palo-alto-ca-homes-for-sale-1765-e-bayshore-rd-214": 2, "home-selling-tips-expert-staging-top-dollar": 2, "bay-area-realtor-essential-real-estate-tips": 2, "Home-Search": 2, "redwood-city-real-estate-prices": 2, "east-palo-alto-california-real-estate": 2, "rise-and-fall-of-silicon-valley-bank": 2, "bay-area-realtor-graeham-watts-off-market-dream-homes": 2, "just-sold-east-palo-alto-real-estate-1765-e-bayshore-rd-203": 2, "real-estate-market-update-stunning-home-oakley-59-escher-circle-tour-features": 2, "broadway-masala-redwood-city-indian-food": 2, "east-bay-real-estate-hidden-deals-maximize-sale": 2, "join-bay-area-realtor-graeham-watts-fiesta": 2, "mortgage-rates-forecast-2025-housing-loan-trends": 2, "shocking-bay-area-house-tour-twist": 2, "iBiB": 2, "bay-area-realtor-graeham-watts-humor-and-real-estate": 2, "bay-area-realtor-client-testimonial-trust": 2, "bay-area-realtor-daily-hustle-inside-look": 2, "graeham-watts-client-testimonials": 2, "Choosing-a-Bay-Area-Realtor": 2, "Understanding-Contingencies": 2, "nEnF": 2, "is-now-the-best-time-to-buy-real-estate": 2, "v4S8": 2, "sell-home-prep-tips": 2, "real-estate-market-update-you-need-to-know": 2, "Home-Selling-Tips": 2, "redwood-city-real-estate-ca-silicon-valley-oasis": 2, "graeham-watts-bay-area-realtor-real-estate-success": 2, "bay-area-realtor-graeham-watts-advantage": 2, "houses-for-sale-east-palo-alto-453-okeefe-st": 1, "east-menlo-park-market-update-home-prices-up": 1, "Real-Estate-Market-Forecast-Save-Thousands": 1, "real-estate-market-forecast-august-trends": 1, "bay-area-realtor-success-story-marketing-results": 1, "Why-Clients-Trust-Graeham": 1, "redwood-city-real-estate-charming-2bd-1ba-home-tour-expansive-yard": 1, "real-estate-market-update-top-investment-websites": 1, "east-palo-alto-market-update-free-home-valuation": 1, "east-palo-alto-homes-for-sale-2620-fordham": 1, "belle-haven-market-update-menlo-park": 1, "real-estate-market-update-sell-home-top-dollar": 1, "east-palo-alto-real-estate-market-update-2023": 1, "homes-for-sale-in-the-bay-area-just-listed-hot-properties": 1, "bay-area-realtor-graeham-watts-staging-and-3d-tours": 1, "rising-interest-rates-and-real-estate": 1, "redwood-city-real-estate-update-sell-high-inventory-rising": 1, "redwood-city-real-estate-update-2022-home-value": 1, "Home-buyer-guide": 1, "houses-in-east-palo-alto-dream-homes-for-every-budget": 1, "menlo-park-houses-for-sale-monthly-market-update": 1, "east-palo-alto-homes-for-sale-stylish-loft": 1, "ULo7": 1, "redwood-city-real-estate-market-update-for-homeowners": 1, "east-palo-alto-california-real-estate-trends": 1, "real-estate-market-trends-2025-home-warranty-explained": 1, "real-estate-inventory-trends-low-inventory-market-impact": 1, "east-palo-alto-real-estate-market-update-feb-2025": 1, "homes-for-sale-in-the-bay-area-san-leandro-modern-family-home": 1, "bay-area-realtor-graeham-watts-client-testimonial": 1, "homes-for-sale-in-farm-hill-7210-eagle-ridge-dr-gilroy-ca": 1, "KII1": 1, "essential-home-selling-tips-biggest-seller-mistakes": 1, "east-palo-alto-ca-homes-for-sale-buyer-tips-1765-e-bayshore-rd-204": 1, "menlo-park-real-estate-market-update-latest-trends-insights": 1, "home-selling-tips-fix-now-pay-later": 1, "homes-for-sale-in-redwood-city": 1, "real-estate-market-forecast-shocker-haunting-2025": 1, "home-selling-tips-out-of-state-overwhelmed-help": 1, "real-estate-update-home-selling-fast": 1, "rare-redwood-city-real-estate-fixer-2-bed-condo-hidden-potential": 1, "redwood-city-real-estate-insights": 1, "game-changing-real-estate-predictions-2025-big-deals-big-results": 1, "home-selling-mistakes-to-avoid": 1, "best-time-to-buy-real-estate-1930-pinole-drive-tour": 1, "ab-1482-unlocked-secrets-every-california-landlord-should-know": 1, "scariest-real-estate-costume-ever": 1, "redwood-city-real-estate-market-update-prices-up-inventory-down": 1, "home-alone-sequel-we-always-wanted-east-palo-alto-homes": 1, "unlock-hidden-redwood-city-real-estate-ca-gems": 1, "beat-the-market-east-palo-alto-market-update-secret-price-bump": 1, "Featured-Property-menlo-park": 1, "redwood-city-real-estate-renovation-alert": 1, "2842-Cornelius-Dr-Tour": 1, "Featured-Property-Redwood-City": 1, "Featured-Property-east-palo-alto": 1, "east-palo-alto-homes-for-sale-1765-bayshore-rd-203": 1, "homes-for-sale-in-east-palo-alto-ca-2398-palgas-ave": 1, "redwood-city-real-estate-2022-sell-high-find-home": 1, "redwood-city-real-estate-showdown": 1, "east-palo-alto-market-update-2022-inventory-and-prices": 1, "east-palo-alto-real-estate-gaillardia-way-property": 1, "real-estate-law-101-ca-landlord-entry-laws-explained": 1, "ab1482-explained-avoid-landlord-mistakes": 1, "client-testimonial-kevin-rebecca-bowe": 1, "east-palo-alto-market-update-2022-sell-for-maximum-value": 1, "real-estate-market-update-city-vs-county-closing-costs": 1, "redwood-city-real-estate-2025-market-shocker-revealed": 1, "discover-stunning-homes-for-sale-east-palo-alto-ca-graeham-watts-advantage": 1, "east-palo-alto-market-update-2022-home-value-increase": 1, "fha-loans-vs-conventional-loans": 1, "Home-Selling-Tips-Must-Know": 1, "price-your-home-right": 1, "thinking-of-selling-watch-this": 1, "home-worth-estimate": 1, "bay-area-realtor-why-experience-matters": 1, "san-mateo-real-estate-update": 1, "redwood-city-real-estate-market-update": 1, "east-palo-alto-real-estate-mark-dinan-vision-for-change-city-council-candidate": 1, "tech-growth-impact-redwood-city-real-estate": 1, "interest-rates-and-real-estate-market-impact": 1, "bay-area-realtor-graeham-watts-difference": 1, "east-palo-alto-houses-14-robin-ct-tour": 1, "unlock-redwood-city-real-estate": 1, "real-estate-market-update-22277-hartman-drive-property-tour": 1, "homes-for-sale-redwood-city-ca-staging-secrets": 1, "mt-carmel-redwood-city-homes-for-sale": 1, "redwood-city-real-estate-market-update-graeham-watts": 1, "redwood-city-real-estate-hidden-gems-revealed": 1, "mortgage-costs-redwood-city-real-estate-ca": 1, "discover-affordable-homes-under-100k-across-america": 1, "redwood-city-ca-real-estate-property-tax": 1, "ab-1482-explained-landlords-tenants-must-know": 1, "homes-for-sale-in-redwood-city-ca": 1, "redwood-city-real-estate-monthly-market-update": 1, "real-estate-market-forecast-foreclosure-myth-busted": 1, "roosevelt-redwood-city-homes-for-sale": 1, "homes-for-sale-east-palo-alto-ca-living-guide": 1, "redwood-city-real-estate-market-update-feb-2025": 1, "redwood-city-real-estate-financing": 1, "essential-home-selling-tips": 1, "essential-home-selling-tips-fourth-step-offer-accepted": 1, "homes-for-sale-in-redwood-city-ca-590-hurlingame-ave-makeover": 1, "homes-for-sale-in-the-bay-area-3500-19th-st-san-francisco": 1, "homes-for-sale-in-east-palo-alto-ca-2288-addison-ave": 1, "homes-for-sale-in-redwood-city-ca-winning-offers-underwriting-edge": 1, "homes-for-sale-in-the-bay-area-786-honeywood-court-pending-sale": 1, "homes-for-sale-in-the-bay-area-graeham-watts-2022-successes": 1, "real-estate-market-update-186-overlook-ave-hayward-ca-virtual-tour": 1, "bay-area-realtor-graeham-watts-client-love": 1, "ab-1482-explained-maximum-rent-increase-limits-in-california": 1, "new-rent-rules-real-estate-predictions-2025-tenants-landlords": 0, "redwood-city-real-estate-moving-guide-what-you-need-to-know": 0, "east-menlo-park-market-update-february-2025": 0, "east-palo-alto-real-estate-moving-guide": 0, "home-selling-tips-make-sales-breeze-no-stress": 0, "epa-comps-0601": 0, "S9z9": 0, "east-palo-alto-dream-home-tour-30-seconds": 0, "redwood-city-real-estate-hidden-secrets": 0, "real-estate-market-trends-2025-alameda-county-hidden-gem": 0, "unbelievable-house-tour-market-forecast": 0, "palm-redwood-city-homes-for-sale": 0, "discover-east-palo-alto-real-estate-february-market-update": 0, "homes-for-sale-in-the-bay-area-soquel-3-bedroom": 0, "exciting-real-estate-market-update-jumping-housing-market": 0, "trusted-bay-area-realtor-house-safety-guarantee": 0, "redwood-city-real-estate-floor-installation-tips": 0, "unexpected-homes-for-sale-bay-area-dream-home": 0, "property-tax-shock-2025-real-estate-forecast": 0, "explore-east-palo-alto-homes-graeham-watts": 0, "101_Graden_St": 0, "ravenswood-school-district-50m-investment-better-schools-higher-teacher-pay": 0, "top-tips-for-selling-homes-quickly-maximize-home-value": 0, "rising-interest-rates-and-real-estate-prices": 0, "homes-for-sale-east-palo-alto": 0, "buy-a-home-east-palo-alto": 0, "redwood-village-homes-for-sale-in-redwood-city": 0, "east-palo-alto-market-update-january-2025-key-insights": 0, "real-estate-market-update-hartman-drive-los-altos-ca-virtual-tour": 0, "bay-area-realtor-secrets-unveiled": 0, "east-palo-alto-next-real-estate-goldmine": 0, "east-palo-alto-ca-homes-for-sale-house-tour": 0, "homes-for-sale-menlo-park-ca-modern-home-tour-1318-hollyburne-avenue": 0, "graeham-watts-east-palo-alto-listings": 0, "real-estate-market-trends-2025-phone-to-realty": 0, "discover-homes-for-sale-east-palo-alto-ca-1560-kavanaugh-drive-tour": 0, "home-selling-tips-busting-3-buyer-myths": 0, "the-buzz-in-redwood-city-real-estate": 0, "east-palo-alto-homes-for-sale": 0, "ab1482-what-landlords-need-to-know-and-do": 0, "eagle-hill-redwood-city-homes-for-sale": 0, "east-menlo-park-homes-for-sale-graeham-watts": 0, "discover-stunning-homes-for-sale-menlo-park": 0, "east-menlo-park-homes-for-sale-listings-graeham-watts": 0, "expert-home-selling-tips": 0, "bay-area-realtor-free-staging-and-repairs": 0, "bay-area-home-search-map": 0, "Ace_Your_First_Home_Purchase": 0, "homes-for-sale-menlo-park-ca-update": 0, "downtown-redwood-city-homes-for-sale": 0, "maximize-your-home-sale-value": 0, "east-menlo-park-homes-for-sale": 0, "redwood-city-real-estate-paradise": 0, "stambaugh-heller-redwood-city-homes-for-sale": 0, "Redwood-Oaks-homes-for-sale-in-redwood-city-ca": 0, "real-estate-market-update-2025-california-landlord-risks": 0, "east-palo-alto-real-estate-housing-market-update": 0, "essential-real-estate-market-update-homebuyers-guide": 0, "redwood-city-real-estate-housing-market-update": 0, "central-redwood-city-homes-for-sale": 0, "east-palo-alto-homes-for-sale-woodland-creek-condo": 0, "discovering-redwood-city-real-estate": 0, "redwood-city-real-estate-crucial-market-update": 0, "menlo-park-market-update-belle-haven-housing-trends": 0, "real-estate-market-forecast-fed-interest-rates-explained": 0, "real-estate-market-update-just-listed-1186-overlook-ave-hayward-ca-house-tour": 0, "centennial-redwood-city-homes-for-sale": 0, "east-palo-alto-real-estate-monthly-market-update": 0, "kIhN": 0, "real-estate-market-trends-2025": 0, "best-indian-restaurant-redwood-city-broadway-masala": 0, "Redwood-City-Homes-on-Sale-Hot-Listings-Price-Drops": 0, "ab-1482-rent-increases-exemptions-and-california-rental-laws-explained": 0, "Redwood-City-Homes-on-Sale-Hot-Listings-Price-Drops-Graeham-Watts": 0, "Redwood-City-Homes-on-Sale": 0, "2115-Clarke-Ave": 0, "Redwood-City-Homes-on-Sale-Hot-Listings": 0, "redwood-city-real-estate-san-francisco-monthly-market-update": 0, "essential-real-estate-market-update-guide-for-homebuyers": 0, "essential-home-selling-tips-second-step-pre-qualification": 0, "choose-the-right-agent": 0, "ab-1482-explained-is-your-property-rent-controlled-or-exempt": 0, "stunning-homes-for-sale-menlo-park-1135-madera-ave-tour": 0, "redwood-city-spanish-style-4-bedroom-friendly-acres": 0, "east-palo-alto-real-estate-market-update": 0, "real-estate-market-update-foundation-tips-when-buying-your-new-home": 0, "real-estate-market-update-tips-to-protect-your-offer-and-investment": 0, "east-palo-alto-real-estate-mark-dinan-vision-change": 0, "what-you-can-buy-redwood-city-houses-for-sale": 0, "redwood-city-real-estate-market-february-update-you-need-to-know": 0, "selling-your-home-just-got-easier": 0}} \ No newline at end of file diff --git a/online-content/switchy/EMAIL-to-Peter-generate-QR.html b/online-content/switchy/EMAIL-to-Peter-generate-QR.html new file mode 100644 index 0000000..73247c8 --- /dev/null +++ b/online-content/switchy/EMAIL-to-Peter-generate-QR.html @@ -0,0 +1,58 @@ + +Peter — Generate the postcard QR + +
+
+
New workflow · Switchy QR
+
Generate & embed the postcard QR codes
+
+
+ +

Hey Peter (Jason),

+ +

New skill set up for you — switchy-engine. From now on you don't make + QR codes by hand. You build the postcard, hand it to Claude in Cowork, and it + generates a tracked QR (so we can see scans and retarget people who scan it), + names and files it correctly in Switchy, drops it on our dashboard, and syncs it to GitHub.

+ +

Install (one time)

+

Download the skill, then in Cowork click "Save skill" to install it. This is the + lightweight, QR-only version — all you need to make postcard QR codes.

+ +

If the button doesn't download, copy this link: + https://graehamwatts.github.io/skills/online-content/switchy/switchy-qr.skill + — or pull from GitHub: Graehamwatts/skills → skills/switchy-qr.

+ +

How to generate a QR (every postcard)

+
    +
  1. Finish the postcard in Canva.
  2. +
  3. In Cowork, say: "Generate a QR code for this postcard" and upload the postcard PDF.
  4. +
  5. Claude takes control of Chrome and opens Switchy. You log in once when it asks + (it can't type your password for you — see login below).
  6. +
  7. Claude then does the rest automatically: creates the tracked link with the right + landing page + UTM + our Meta/Google pixels, files it in the "Post card qr" folder, + names it clearly, opens the QR designer, and downloads the QR PNG for you.
  8. +
  9. You drop that QR into the Canva postcard and export for print.
  10. +
  11. Claude adds the link to our Switchy dashboard and syncs it to GitHub.
  12. +
+ +

The one that's already done (your test)

+

The June 1, 2026 EPA "Last 5 Homes" card is already created so you can see how it works:
+ Short link: hi.switchy.io/epa-comps-0601 · Folder: Post card qr · + goes to the home-value page. Pull up that QR in Switchy to grab it.

+ +

Switchy login

+
+ Graeham — fill this in before sending (don't put the password in the email body).
+ Recommended: add Peter as a Switchy team member (Account → Team) so he gets his own login.
+ Login: ________________________  ·  Password: sent separately +
+ +

Naming (Claude handles this automatically)

+

+ Link name: Postcard EPA 2026-06-01 — L \ No newline at end of file diff --git a/online-content/switchy/Farming_Postcard_EPA_06_01_26_IMPROVED.html b/online-content/switchy/Farming_Postcard_EPA_06_01_26_IMPROVED.html new file mode 100644 index 0000000..2355424 --- /dev/null +++ b/online-content/switchy/Farming_Postcard_EPA_06_01_26_IMPROVED.html @@ -0,0 +1,127 @@ + +Farming Postcard EPA 06/01/26 — Improved + + + + +

+ +
Front — improved
+
+
+
+
WHAT DID THE
+
LAST 5 HOMES
+
ON YOUR STREET REALLY SELL FOR?
+
FLIP OVER for your free home-value report  →
+
+ Graeham + +
+
+
+ Intero + Graeham Watts +
+
+
REALTOR®
+
The Martin Team
+
DRE #01466876
+
650-308-4727
+
graehamwatts@gmail.com
+
www.graehamwatts.com
+
+
+
+ +
Back — improved
+
+
+ Graeham +
+
Your street · Free · 60 seconds
+
ZILLOW GUESSES.
I MEASURE.
+
Scan for the actual sale prices on YOUR street — plus what your home would sell for in today’s market. Real comps, not a zip-code guess.
+
    +
  • Real comps on your street — not your zip
  • +
  • Updated this week
  • +
  • No follow-up unless you ask
  • +
+
+
SWITCHY QR
(token pending)
+
+
SEE THE
+
REAL NUMBERS
+
Scan → your street’s real numbers in 60 seconds
+
— Graeham
+
+
+
+
+
+ Intero + Graeham Watts +
+
+
REALTOR®
+
The Martin Team
+
DRE #01466876
+
650-308-4727
+
graehamwatts@gmail.com
+
www.graehamwatts.com
+
+
+
+
If your home is listed with another broker, please disregard this postcard. Homes not necessarily sold by this broker.
+
+ +
+ \ No newline at end of file diff --git a/online-content/switchy/SWITCHY-INTEGRATION-BRIEF.md b/online-content/switchy/SWITCHY-INTEGRATION-BRIEF.md new file mode 100644 index 0000000..71e6dec --- /dev/null +++ b/online-content/switchy/SWITCHY-INTEGRATION-BRIEF.md @@ -0,0 +1,152 @@ +# Switchy Integration — Decision Brief + +Prepared for Graeham Watts · 2026-05-28. Companion files live in the +`switchy-engine/` skill folder. + +--- + +## 1. Architecture recommendation — build the standalone engine (validated) + +Build **`switchy-engine`** as a standalone skill that newsletter, content engine, +postcards, listings, etc. **call into** — with one refinement: the durable +constants (pixel IDs, default domain, tag vocabulary, exclusion-list location) go +in `shared-references/switchy.json` so the engine and every caller read one source. + +I didn't just accept the hypothesis — I pressure-tested it against your actual +stack, and it holds *because of receipts in the code itself*: + +- Your skills already use "build once, reference everywhere" (`cma-generator` + called by the newsletter, `identity.json` shared, `github-skill-sync` as a + horizontal utility). +- Your `content-creation-engine` changelog literally documents the opposite + approach failing: `video-research-engine` went **dormant** when buried inside a + host skill and had to be extracted into `video-watcher`. Embedding link/pixel + logic in the newsletter or content engine would repeat that mistake. +- Duplicating it per-skill would copy **token-handling code into 6+ places** — six + ways to leak a credential and guarantee the pixel list/tag vocab drift apart. + +Full reasoning + the per-skill wiring table: `switchy-engine/references/architecture-decision.md`. + +**Where each skill plugs in:** +- **newsletter-generator** → wrap every EPA Report CTA (highest-value: opted-in consumers). +- **content-creation-engine** → wrap YouTube CTAs, social links, link-in-bio. +- **html-email** → wrap *consumer* emails only; B2B/coach emails track-only. +- **weekly-listing-update** → track-only (audience is one known seller). +- **listing-remarks-writer** → **no wrap** — MLS public remarks legally can't carry + URLs. Tracked links go on the listing's collateral (property page, flyers, QR), not the remarks. +- **postcards** → **the gap** (see §5). + +## 2. Retargeting pathway map + +Full table (29 surfaces, with traffic type / value / pixel-or-not / caveat): +`switchy-engine/references/retargeting-pathway-map.md`. The three highest-leverage +buckets: + +1. **Offline→online bridge** (postcards, yard riders, open-house QR, mailers, + window cards) — traffic you *cannot pixel any other way*. A QR scan converts a + physical mail drop into a digital retargeting audience. +2. **Non-owned platforms** (Zillow, Realtor.com, Nextdoor, GBP posts, social bios) + — you can't put your pixel on their pages, so the redirect is the only hook. +3. **Per-source attribution at scale** (newsletter sections, listings, campaigns) + — tagging tells you which surface actually built the audience. + +## 3. GBP answer — NOT the website field; YES posts + secondary links + +Google's Business links policy prohibits URLs that "redirect or refer" users +elsewhere, and Google now auto-removes violating links; shorteners in the +**primary website field** are a known enforcement target (real cases of links +getting pulled). So: + +- **Primary website field:** real domain only (`graehamwatts.com`), pixeled + natively. Don't risk your map-pack click. +- **GBP posts / appointment / secondary links:** Switchy is safe and is the right + home for GBP retargeting. +- **Headline GBP play:** *GBP post link → YouTube channel* pixels every + high-intent local searcher who clicks, then retargets them. Also: post→listing, + post→home-value form, appointment→GHL booking. + +Detail: `switchy-engine/references/gbp-and-youtube.md`. + +## 4. YouTube / own-site answer — pixel is redundant, link still isn't + +When a Switchy link points to your **own already-pixeled site**, the pixel-drop is +largely redundant (your site tags pixel them on load anyway). But Switchy still +earns its place for four non-pixel reasons: **per-source attribution**, +**swappable destination** (change a printed QR's target without reprinting), +**multi-pixel firing** (fire Meta+Google+LinkedIn from one link), and +**pixel-fires-before-page-load** (catches bouncers your on-site pixel misses). + +Per-surface rule: **non-owned destination → always wrap (essential). Own pixeled +site → wrap only if you want attribution/swap/multi-pixel/pre-load capture; +otherwise raw URL is fine.** Print/QR pointing to your own site → still wrap (swap +value alone). Table in the same reference file. + +## 5. The postcard gap (flagged) + +There is **no postcard skill** — it's a manual Canva workflow, and its QR codes are +the single biggest missed retargeting opportunity (offline→online, un-pixelable +otherwise). Recommendation: short-term, have the Canva workflow mint its QR via +`switchy-engine` (one tagged tracked link per drop/ZIP); later, build a thin +`postcard-engine` that calls the engine automatically. Defer the new skill until +the link engine is live. + +## 6. Audience hygiene tradeoff — don't pixel everything + +Pixeling tiny or B2B traffic pollutes audiences and burns spend (showing listing +ads to your title rep; sub-100 audiences you can't even target). + +- **PIXEL:** newsletter, SMS, listings, GBP posts, Zillow/Realtor, social bios, + YouTube links, all offline QR. +- **SKIP/track-only:** email signature, LinkedIn, peer business cards, sphere/PCFS + touches (mixed/B2B/known). +- **Segment** via mandatory `audience-class` tag (`consumer`/`prospect`/`b2b`/ + `mixed`) on every minted link; build ad audiences from `consumer`+`prospect` + **minus a standing vendor/agent exclusion list**; never spend against a + sub-1,000 standalone audience; build lookalikes only from clean seeds. + +Detail: `switchy-engine/references/audience-hygiene.md`. + +## 7. The scaffolded skill + working query + +`switchy-engine/` contains: +- `SKILL.md` — engine definition, API facts, token setup, the mint/report contract. +- `scripts/switchy_analytics.py` — secure token handling (env/file, never hardcoded), + schema introspection, the per-link analytics query (scalar + aggregate fallback), + and the **scans → audience → budget** table. Runs in DEMO mode without a token; + I've verified it executes and renders. +- `references/` — queries, pathway map, GBP/YouTube answers, audience hygiene, + architecture decision. +- `sample_switchy_report.md/.csv` — sample output (illustrative numbers). +- `.gitignore` — keeps the token and workspace data out of version control. + +The budget model: `audience = clicks × pixel-match-rate (55%)`; `monthly budget = +audience × frequency (10×) × CPM ($22) / 1000`. All three are CLI-tunable. It's the +spend an audience can *absorb*, not a target. + +--- + +## What I need from you to finish (the asks) + +1. **Token activation — the blocker.** Confirm whether your Switchy API token is + actually enabled. Generate it (Workspace → Settings → Integrations → Generate a + token); if the smoke-test query returns errors/empty, message Switchy **live + chat** to enable API access, then regenerate. Until this returns rows, I can't + lock the real click/scan field name or pull live numbers. +2. **Decision — GDPR popup.** Default `showGDPR: true` on cold consumer links (CA, + safer, slightly lower match) vs. `false` (higher match, more exposure). Your call. +3. **Constants for `shared-references/switchy.json`:** your Meta pixel ID, GA + measurement ID (and any LinkedIn/Pinterest/Bing pixels you want fired), and your + preferred default Switchy domain. I'll wire these once you provide them. +4. **Vendor/agent exclusion list** — a CSV of known peers/vendors/team emails to + stand up the standing exclusion audience. +5. **Approve the per-skill wiring** in §1 before I edit the live skills (right now + `switchy-engine` is built but not yet referenced by newsletter/content/etc.). +6. **TikTok** — accept GTM-routed or click-only tracking (no native Switchy TikTok + pixel), or drop TikTok from the pixel plan. + +### Two accuracy flags (per your rules) +- The **per-link click/scan GraphQL field name is unverified** — public docs only + show workspace-level fields. The script introspects it first; I did **not** + fabricate a field name. Treat the analytics query as confirmed only after + `--confirm-schema` runs on your live token. +- The sample report numbers are **illustrative DEMO data**, not real. diff --git a/online-content/switchy/switchy-engine.skill b/online-content/switchy/switchy-engine.skill new file mode 100644 index 0000000..3a849d5 Binary files /dev/null and b/online-content/switchy/switchy-engine.skill differ diff --git a/online-content/switchy/switchy-qr.skill b/online-content/switchy/switchy-qr.skill new file mode 100644 index 0000000..1f97908 Binary files /dev/null and b/online-content/switchy/switchy-qr.skill differ diff --git a/online-content/switchy/switchy_LIVE_report.md b/online-content/switchy/switchy_LIVE_report.md new file mode 100644 index 0000000..03c258d --- /dev/null +++ b/online-content/switchy/switchy_LIVE_report.md @@ -0,0 +1,452 @@ +# Switchy Retargeting Report — 2026-05-28 20:30 + +_Data source: LIVE Switchy API (env:SWITCHY_API_TOKEN)_ +_Model: pixel match 55%, freq 10x / 30d, CPM $22_ + +| Short link | Tags | Destination | Clicks | Audience | Monthly budget | Status | +|---|---|---|---:|---:|---:|---| +| pages.graehamwatts.com/bay-area-homes-for-sale | | https://graehamwatts.com/bay-area-homes-fo… | 1,067 | 587 | $129 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SbSR | | https://graehamwatts.com/sell-with-graeham… | 1,023 | 563 | $124 | Thin — fold into a combined audience | +| pages.graehamwatts.com/graehamwatts-meet | | https://tidycal.com/graehamwatts/30-minute… | 776 | 427 | $94 | Thin — fold into a combined audience | +| pages.graehamwatts.com/whats-my-house-worth | | https://graehamwatts.com/evaluation | 722 | 397 | $87 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SKZY | | https://graehamwatts.com/evaluation?utm_ca… | 681 | 375 | $82 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SKaA | | https://graehamwatts.com/meet-with-graeham… | 602 | 331 | $73 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SK0r | | https://graehamwatts.com/evaluation?utm_ca… | 585 | 322 | $71 | Thin — fold into a combined audience | +| pages.graehamwatts.com/XhI5 | | https://graehamwatts.com/2271-euclid-ave?u… | 570 | 314 | $69 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SK1P | | https://graehamwatts.com/evaluation?utm_ca… | 547 | 301 | $66 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SKai | | https://graehamwatts.com/sell-with-graeham… | 515 | 283 | $62 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SKY8 | | https://graehamwatts.com/buy-with-graeham?… | 509 | 280 | $62 | Thin — fold into a combined audience | +| pages.graehamwatts.com/redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-ca-h… | 496 | 273 | $60 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SKYl | | https://graehamwatts.com/buy-with-graeham?… | 460 | 253 | $56 | Thin — fold into a combined audience | +| pages.graehamwatts.com/east-palo-alto-ca-homes-for-sale | | https://graehamwatts.com/east-palo-alto-ca… | 429 | 236 | $52 | Thin — fold into a combined audience | +| pages.graehamwatts.com/homes-for-sale-belle-haven | | https://graehamwatts.com/east-menlo-park | 397 | 218 | $48 | Thin — fold into a combined audience | +| pages.graehamwatts.com/T1td | | https://graehamwatts.com/59-escher-cir?utm… | 396 | 218 | $48 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SJob | | https://graehamwatts.com/buy-with-graeham?… | 376 | 207 | $46 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SJ-k | | https://graehamwatts.com/evaluation?utm_ca… | 296 | 163 | $36 | Thin — fold into a combined audience | +| hi.switchy.io/MFUy | | https://graehamwatts.com/ | 281 | 155 | $34 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SXDB | | https://graehamwatts.com/sell-with-graeham… | 234 | 129 | $28 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SM9A | | https://graehamwatts.com/sell-with-graeham… | 205 | 113 | $25 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SKZH | | https://graehamwatts.com/buy-with-graeham?… | 190 | 105 | $23 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SRlP | | https://graehamwatts.com/sell-with-graeham… | 185 | 102 | $22 | Thin — fold into a combined audience | +| pages.graehamwatts.com/SKDr | | https://graehamwatts.com/buy-with-graeham?… | 180 | 99 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SX1b | | https://graehamwatts.com/sell-with-graeham… | 170 | 94 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SJoH | | https://graehamwatts.com/buy-with-graeham?… | 163 | 90 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SKYx | | https://graehamwatts.com/buy-with-graeham?… | 141 | 78 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SJpt | | https://graehamwatts.com/buy-with-graeham?… | 141 | 78 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-CA-homes-for-sale-under-1million | | https://graehamwatts.com/east-palo-alto-ca… | 136 | 75 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SJ-D | | https://graehamwatts.com/buy-with-graeham?… | 126 | 69 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SX7L | | https://graehamwatts.com/sell-with-graeham… | 124 | 68 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SK0T | | https://graehamwatts.com/buy-with-graeham?… | 123 | 68 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S-VQ | | https://graehamwatts.com/sell-with-graeham… | 116 | 64 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/T1nG | | https://graehamwatts.com/59-escher-cir?utm… | 111 | 61 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SXGz | | https://graehamwatts.com/sell-with-graeham… | 108 | 59 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SKYW | | https://graehamwatts.com/buy-with-graeham?… | 105 | 58 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/East-palo-alto-home-for-sale | | https://graehamwatts.com/east-palo-alto-ca… | 96 | 53 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/menlo-park-homes-for-sale | | https://graehamwatts.com/east-menlo-park?u… | 94 | 52 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/how-much-is-my-home-worth | | https://bit.ly/4a7ScVM | 93 | 51 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/T1r5 | | https://graehamwatts.com/2842-cornelius-dr… | 90 | 50 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S-Vz | | https://graehamwatts.com/sell-with-graeham… | 88 | 48 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-home-for-sale | | https://graehamwatts.com/redwood-city-home… | 80 | 44 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/T8PU | | https://graehamwatts.com/sell-with-graeham… | 78 | 43 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/TA7m | | https://graehamwatts.com/sell-with-graeham… | 71 | 39 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SX5l | | https://graehamwatts.com/sell-with-graeham… | 70 | 38 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SXG2 | | https://graehamwatts.com/sell-with-graeham… | 70 | 38 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S9yj | | https://graehamwatts.com/sell-with-graeham… | 67 | 37 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/XhH8 | | https://graehamwatts.com/2271-euclid-ave?u… | 61 | 34 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/schedule-bay-area-real-estate-meeting | | https://tidycal.com/graehamwatts/30-minute… | 60 | 33 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SKYI | | https://graehamwatts.com/buy-with-graeham?… | 60 | 33 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/blog-east-palo-alto-ca-homes-for-sale | | https://graehamwatts.com/east-palo-alto-ca… | 59 | 32 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S-Vg | | https://graehamwatts.com/sell-with-graeham… | 59 | 32 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/free-home-evaluation | | https://graehamwatts.com/evaluation | 59 | 32 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S9yo | | https://graehamwatts.com/?utm_campaign=Sel… | 54 | 30 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/T1v5 | | https://graehamwatts.com/2842-cornelius-dr… | 51 | 28 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SM92 | | https://graehamwatts.com/buy-with-graeham?… | 50 | 28 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SXBD | | https://graehamwatts.com/sell-with-graeham… | 48 | 26 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S9xP | | https://graehamwatts.com/sell-with-graeham… | 41 | 23 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SX4- | | https://graehamwatts.com/sell-with-graeham… | 41 | 23 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/T8Yl | | https://graehamwatts.com/sell-with-graeham… | 41 | 23 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SKXy | | https://graehamwatts.com/buy-with-graeham?… | 39 | 21 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-ca-homes-for-sale | | https://graehamwatts.com/redwood-city-ca-h… | 33 | 18 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-forecast-save-thousands-on-your-mortgage-hack | | https://youtu.be/Swqu9xssJFE | 28 | 15 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| hi.switchy.io/formly_email | | https://getformly.app/m0ZTE9 | 28 | 15 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/WVkH | | https://graehamwatts.com/5636-orchard-park… | 28 | 15 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S9w- | | https://graehamwatts.com/?utm_campaign=Sel… | 27 | 15 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-real-estate-market-update-2026-01 | | https://youtu.be/LI92G9EpfRg | 27 | 15 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SKXW | | https://graehamwatts.com/buy-with-graeham?… | 24 | 13 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Meet-with-Graeham | | https://link.graehamwatts.com/widget/booki… | 22 | 12 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S9xr | | https://graehamwatts.com/sell-with-graeham… | 22 | 12 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Home-For-Sale | | https://graehamwatts.com/listing?_gl=1*dmy… | 20 | 11 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SM9K | | https://graehamwatts.com/sell-with-graeham… | 20 | 11 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park | | https://graehamwatts.com/east-menlo-park | 19 | 10 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/sell-with-graeham | | https://graehamwatts.com/sell-with-graeham… | 19 | 10 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/5636-orchard-park-drive-san-jose-ca-95123 | | https://graehamwatts.com/5636-orchard-park… | 17 | 9 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/why-sell-with-graeham | | https://graehamwatts.com/whats-my-east-pal… | 16 | 9 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discover-east-menlo-park-homes | | https://graehamwatts.com/east-menlo-park | 15 | 8 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Belle-haven-home-value-evaluation | | https://graehamwatts.com/evaluation | 14 | 8 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SKaM | | https://graehamwatts.com/meet-with-graeham… | 13 | 7 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-redwood-city-ca | | https://graehamwatts.com/redwood-city-ca-h… | 13 | 7 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-sold-40k-more-with-50k-issue | | https://youtu.be/IOMKVGUeET0 | 13 | 7 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| hi.switchy.io/get_free_consultation | | https://graehamwatts.com/ppc-lp?fromCms=1 | 13 | 7 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-sell-your-home-quickly | | https://youtu.be/UR8raNurlrw | 12 | 7 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homevalue | | https://graehamwatts.com/whats-my-east-pal… | 11 | 6 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/v4Sk | | https://youtube.com/shorts/Uc5GU2m3lVQ?fea… | 10 | 6 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S9y0 | | https://graehamwatts.com/?utm_campaign=Sel… | 10 | 6 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| hi.switchy.io/before_after_1930_Sarah_Dr | | https://youtu.be/cwCtzfaTFBU | 9 | 5 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SX9V | | https://graehamwatts.com/sell-with-graeham… | 9 | 5 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S9z0 | | https://graehamwatts.com/sell-with-graeham… | 9 | 5 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/schedule-call-with-graeham | | https://tidycal.com/graehamwatts/30-minute… | 9 | 5 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/woodside-plaza-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-wood… | 9 | 5 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-condo-1982-w-bayshore-223 | | https://graehamwatts.com/listing-detail/11… | 9 | 5 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/essential-home-selling-tips-5-Biggest-Mistakes | | https://www.youtube.com/watch?v=t5K0mSd9vho | 8 | 4 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-east-palo-alto-ca-discover-your-dream-home | | https://youtu.be/9czXxf4eV98 | 7 | 4 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/952-6th-AVE-Redwood-City-CA-94063 | | https://graehamwatts.com/listing-detail/11… | 7 | 4 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/828-Weeks-ST | | https://graehamwatts.com/listing-detail/11… | 7 | 4 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-safety-tips | | https://youtu.be/tcXNFmlJ5-A | 7 | 4 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/free-home-valuation | | https://graehamwatts.com/evaluation?_gl=1*… | 7 | 4 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Home-Buying-Myths | | https://youtu.be/I5cgy1ck4Gc | 7 | 4 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-expert-advice-you-can-trust | | https://youtu.be/AMCRuyMPwq0 | 6 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-470-bell-st-tranquil-home-tour-silicon-valley | | https://youtu.be/jkRO-VHB4rw | 6 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-forecast-fed-rate-cuts-2025-housing-market | | https://youtube.com/shorts/TJ1s6lGQQhs | 6 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-winnie-danny-dream-home | | https://youtu.be/x3P7pQgX4c4 | 6 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-transformed-sold-for-top-dollar | | https://youtu.be/cwCtzfaTFBU | 6 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-condos-for-sale-woodland-creek-223 | | https://youtu.be/jm59JyV3fJk | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/3-Buyer-Myths | | https://62bygw.hippovideo.io/page/graehamw… | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/selling-homes-quickly-tips-maximize-home-value | | https://youtu.be/oQTZdIt8gSs | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-insights-650k-fixer-upper-dream-home | | https://youtube.com/shorts/FoCq1qIOD64 | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Home-Evaluation | | https://graehamwatts.com/whats-my-east-pal… | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-dream-home-client-testimonial | | https://youtu.be/jDPAsX80XCU | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/2025-california-landlord-risk-update | | https://www.youtube.com/watch?v=DYlXSFnO-5M | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Why-Clients-Trust-Graeham-Watts | | https://youtu.be/_UZPXHYIwHc | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-menlo-park-graeham-watts-tour | | https://youtu.be/9C35wqF79wk | 5 | 3 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-cost-of-living | | https://youtu.be/qe8FdzkNPc8 | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Proven-Home-Selling-Tips | | https://youtu.be/eYS4AWn92-8 | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-homes-for-sale-431-larkspur-dr-record-sale | | https://youtu.be/HUfZa1kIt-g | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-market-update-prices-rising-homes-selling-fast | | https://youtu.be/IZV4sljzPcs | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| hi.switchy.io/KII7 | | sms:+16503084727?&body=Hey%2C%20Graeham%20… | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-470-bell-st-listing | | https://youtu.be/Fr7wU3SkAAk | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/avoid-costly-homebuyer-errors-redwood-city-real-estate-ca | | https://youtu.be/2nLZQ6xOoRw | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SJpA | | https://graehamwatts.com/buy-with-graeham?… | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-313-smithwood-milpitas-ca-95035-home-tour | | https://youtu.be/ueh_94DC2-Y | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Offer-Accepted | | https://62bygw.hippovideo.io/page/graehamw… | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/top-east-palo-alto-realtor-sell-home-fast-top-dollar | | https://www.youtube.com/shorts/rmREybVznQA | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/top-east-palo-alto-realtor-home-selling-tips | | https://www.youtube.com/shorts/rmREybVznQA | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discover-homes-for-sale-redwood-city-ca-spanish-style-4-bedroom-friendly-acres | | https://youtube.com/shorts/s5ANgnt78Yo | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/san-jose-home-for-sale-500k-assumable-rate | | https://youtu.be/NNs77CwRI3g | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/How-We-Price-Your-Home | | https://62bygw.hippovideo.io/page/graehamw… | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-homes-for-sale-123-main-st-tour | | https://youtu.be/PRPE4iykKJ4 | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-monthly-market-update-Graeham-watts | | https://youtu.be/wEGx9jY_lJY | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/houses-in-east-palo-alto-modernized-1239-jervis-ave | | https://youtu.be/PRPE4iykKJ4 | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/East-Palo-Alto-Real-Estate-Monthly-Market-Update-graeham-watts | | https://youtu.be/ERx55bk7Ac0 | 4 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-missed-ca-fee-amnesty-act-now | | https://youtu.be/ji4bzCdj-aw | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-realtor-sell-home-fast-top-dollar | | https://youtube.com/shorts/rmREybVznQA | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-testimonial-savings | | https://youtu.be/zX5JB2RuXsY | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/1457-quail-st-los-banos-4-bedroom-home | | https://youtube.com/shorts/Lf-0Jq-KCa4 | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/affordable-east-palo-alto-homes-14-robin-court-tour | | https://youtube.com/shorts/-DF6_AEZ6s0 | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-free-home-staging-maximize-value | | https://youtu.be/oSYwa8rn7qg | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-predictions-2025-east-menlo-park | | https://youtu.be/MhavOznsxdI | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-graeham-watts-love-this-city | | https://www.youtube.com/shorts/CmeYtuyutTw | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-california-rent-rules | | https://youtu.be/DWf1Zm5XCLE | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-supply-and-demand-toilet-paper-bidding-war | | https://youtu.be/9U3IflXKGdg | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-maximize-sale-price-fast | | https://youtu.be/eYS4AWn92-8 | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/big-head-big-ideas-big-results-real-estate-predictions-2025 | | https://youtube.com/shorts/rmREybVznQA | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-johnny-ashley-success | | https://youtu.be/R-vWlOU5PvQ | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ca-rent-laws-in-crises | | https://youtu.be/qOOcIe-3NP4 | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-market-update-graeham-watts | | https://youtu.be/UebeV3jVfy4 | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/essential-home-selling-tips-third-step-home-search | | https://youtu.be/DTuZtG-eOLQ | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-ryan-teeda-journey | | https://youtu.be/K1thMc06p2M | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/1908-Cooley-AVE | | https://graehamwatts.com/listing-detail/11… | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-market-update-prices-up-inventory-low | | https://youtu.be/Thl3cu12BKg | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bair-island-homes-for-sale-in-redwood-city | | https://graehamwatts.com/redwood-city-bair… | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-real-estate-march-market-update | | https://www.youtube.com/watch?v=8tG7RqPXIAo | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/The-Graeham-Watts-Advantage | | https://62bygw.hippovideo.io/s/k4EWYg3P? W… | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-buy-sell-with-graeham-watts | | https://youtu.be/d-1do_fHc7A | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-march-real-estate-market-update | | https://www.youtube.com/watch?v=fjDbun9DY5I | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discover-east-palo-alto-real-estate-stunning-home-under-900k | | https://youtu.be/ERozyMaZkGg | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-homes-for-sale-hidden-gems-affordable-living | | https://youtu.be/KqvEa_SdxNU | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-off-market-secrets | | https://youtu.be/pfTUOCWSiM4 | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-forecast | | https://youtu.be/aM4qx_1v26k | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-market-update-are-you-keeping-up | | https://www.youtube.com/watch?v=Sz4jRWTZgbI | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-ravenswood-school-district-transformation | | https://youtu.be/gdytpSbZBeQ | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-ca-homes-for-sale-woodland-creek-condo-tour | | https://youtu.be/4z_EXVd2aiw?utm_campaign=… | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-insider | | https://youtu.be/IOMKVGUeET0 | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/client-testimonial-moneisha-jermell | | https://youtu.be/wyiKWpj852w | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-hidden-gems | | https://youtu.be/n8lNXk3QTUM | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-dream-home-success | | https://youtu.be/D6FOBstUpPM | 3 | 2 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-ca-house-for-sale-worth-investment | | https://youtu.be/hOcUeiAWhhI | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-homes-selling-over-asking | | https://youtu.be/4hWHkLPj7vU | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-east-palo-alto-ca-431-larkspur-dr | | https://youtu.be/GYtNQ18PKSg | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-why-now-is-the-time-to-buy | | https://youtu.be/FpaTXnmdtyI | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/belle-haven-market-update | | https://youtu.be/JRdenb9WY68 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/unleashing-potential-east-palo-alto-real-estate-transformative-fixer-upper | | https://youtu.be/q5sc-22AauA | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-east-palo-alto-ca-117-mission-drive | | https://youtu.be/yYs6poUDo3g | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-record-home-sale | | https://youtu.be/mZ5d1OXOiBk | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-homes-for-sale-757-douglas-ave-tour | | https://youtu.be/P_xG-3Vbvwk | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-menlo-park-home-value | | https://youtu.be/sOCqkU8pzvY | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-homes-for-sale-2620-fordham-st-update | | https://youtu.be/lWQmnzSH69s | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/friendly-acres-redwood-city | | https://youtu.be/q3ZqNbKcxs4 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-condos-for-sale | | https://youtu.be/5DkZ2Omhn0E | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-living-expense | | https://youtu.be/uXimfvpyQoU | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-market-update | | https://youtu.be/xrhk16hEQkk | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/max-profit-selling-homes-redwood-city | | https://youtu.be/J911WGvePPc | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/graeham-watts-advantage-redwood-city-real-estate | | https://youtu.be/8O_PDLpfRIo | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-ca-homes-for-sale-2109-myrtle-pl-tour | | https://youtu.be/SqOzKVyRUWc | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-shores-homes-for-sale-luxury-waterfront-tour | | https://youtu.be/tqKSADgRm8Q | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/menlo-park-houses-for-sale-breathtaking-transformation | | https://youtu.be/jz3XXxRmL88 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/houses-for-sale-in-east-palo-alto-1404-camellia-drive | | https://youtu.be/mpkKH9kGCQU | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/houses-in-east-palo-alto-952-newbridge-st-real-estate-gem | | https://youtu.be/ViQurCKu2yA | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-client-testimonial-john-ward | | https://youtu.be/X3hp3FH_OLc | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-attract-perfect-buyers | | https://youtu.be/5Bg2RaEf2TU | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-menlo-park-2022-trends | | https://youtu.be/_aiC3xKvFII | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-trusted-expert | | https://youtu.be/QWrMu_NQZ3A | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-get-top-dollar-for-your-property | | https://youtu.be/Lh_1XIsn5Tk | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-los-gatos-home-sells-big | | https://youtu.be/cjq9DDMAteg | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-luxury-the-westerly | | https://youtu.be/PTDk3BnFfF4 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-ca-safer | | https://youtu.be/ihNmmiZCb_4 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-next-big-investment | | https://youtu.be/yCnb-wXOeN4 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-menlo-park-market-trends-graeham-watts | | https://youtu.be/F_zJJ71-7Us | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-home-alone-reaction | | https://youtu.be/j4D7UP38nuw | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-january-market-update | | https://youtu.be/sPtJJjuLNAc | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-market-update-january-2025 | | https://youtu.be/CIxAjHekyR0 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-homeowner-update | | https://youtu.be/N6dfSAq35ng@gmail.com | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-charming-dream-homes | | https://youtu.be/dshlY4NEDkY | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-march-real-estate-market-update-2025 | | https://www.youtube.com/watch?v=fjDbun9DY5I | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-market-home-value-insight | | https://youtu.be/DDkpLjrXlQg | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/selling-homes-quickly-tips-kitchen-countertops | | https://youtu.be/CNePiFgQloo | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SK1C | | https://graehamwatts.com/buy-with-graeham?… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/SM9O | | https://graehamwatts.com/sell-with-graeham… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-ca-homes-for-sale-1765-e-bayshore-rd-214 | | https://youtu.be/F_9aCy48xiA | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-expert-staging-top-dollar | | https://youtu.be/wojWWH-c1-g | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-essential-real-estate-tips | | https://youtu.be/4yHmZf3_cK0 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Home-Search | | https://62bygw.hippovideo.io/s/Xodgg8bk? W… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-prices | | https://youtu.be/nxmsOFoDNak | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-california-real-estate | | https://youtu.be/NXxYWfxCpu8 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/rise-and-fall-of-silicon-valley-bank | | https://youtu.be/1qnDcx755mk | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-off-market-dream-homes | | https://youtu.be/qksoxPL1OS4 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/just-sold-east-palo-alto-real-estate-1765-e-bayshore-rd-203 | | https://youtu.be/npwvcGCvJaU | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-stunning-home-oakley-59-escher-circle-tour-features | | https://youtu.be/L0oPoggLJXs | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/broadway-masala-redwood-city-indian-food | | https://www.youtube.com/watch?v=igwB8ZVOj7Y | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-bay-real-estate-hidden-deals-maximize-sale | | https://www.youtube.com/shorts/tj7luRmP6hE | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/join-bay-area-realtor-graeham-watts-fiesta | | https://www.youtube.com/shorts/kICnNSf9yt8 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/mortgage-rates-forecast-2025-housing-loan-trends | | https://www.youtube.com/shorts/M2aq5ab09XI | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/shocking-bay-area-house-tour-twist | | https://www.youtube.com/shorts/JW2CEG3xOn0 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/iBiB | | https://calendly.com/pat-brunner/30min?mon… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-humor-and-real-estate | | https://youtu.be/FtXHg1K89Fk | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-client-testimonial-trust | | https://youtu.be/_UZPXHYIwHc | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-daily-hustle-inside-look | | https://youtu.be/hDmcb6z23v4 | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/graeham-watts-client-testimonials | | https://youtu.be/-KsvYprH3qA | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Choosing-a-Bay-Area-Realtor | | https://62bygw.hippovideo.io/page/graehamw… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Understanding-Contingencies | | https://62bygw.hippovideo.io/page/graehamw… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/nEnF | | https://app.box.com/s/wjmikb88phzfjpml5jbm… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/is-now-the-best-time-to-buy-real-estate | | https://youtu.be/cxAoaqG-Uqk | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/v4S8 | | https://app.box.com/s/k1rzueil7beea2l089ot… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/sell-home-prep-tips | | https://drive.google.com/file/d/1VyIGSRMAz… | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-you-need-to-know | | https://youtu.be/BlAWo571LKs | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Home-Selling-Tips | | https://youtu.be/Lh_1XIsn5Tk | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-ca-silicon-valley-oasis | | https://youtu.be/M1EhjP0be6g | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/graeham-watts-bay-area-realtor-real-estate-success | | https://youtu.be/sAY8s59amvI | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-advantage | | https://youtu.be/0CuOueT7orQ | 2 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/houses-for-sale-east-palo-alto-453-okeefe-st | | https://youtu.be/42K4c5tIJEU | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-forecast-august-trends | | https://youtu.be/dj6wor-GUME | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-market-update-home-prices-up | | https://youtu.be/USUMOzPTYoA | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Real-Estate-Market-Forecast-Save-Thousands | | https://youtu.be/Swqu9xssJFE | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-success-story-marketing-results | | https://youtu.be/KX8LKsRAi4Y | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Why-Clients-Trust-Graeham | | https://62bygw.hippovideo.io/page/graehamw… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-charming-2bd-1ba-home-tour-expansive-yard | | https://youtu.be/T6PxDSQ0gKw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-top-investment-websites | | https://youtu.be/FAHWVteiHSc | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-market-update-free-home-valuation | | https://youtu.be/PdIi0jNDZUM | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-homes-for-sale-2620-fordham | | https://youtu.be/G8QEsqgvEuw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/belle-haven-market-update-menlo-park | | https://youtu.be/EK67h0ASaMo | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-sell-home-top-dollar | | https://youtu.be/ldOkUcZHAF8 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-market-update-2023 | | https://youtu.be/X4v_FmagjpQ | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-just-listed-hot-properties | | https://youtu.be/VM_85hkCqp4 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-staging-and-3d-tours | | https://youtu.be/wjZi8StZPqg | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/rising-interest-rates-and-real-estate | | https://youtu.be/gqAN-5bmvqA | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-update-sell-high-inventory-rising | | https://youtu.be/-d8go2bi1mY | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-update-2022-home-value | | https://youtu.be/bBy7vxYDYys | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Home-buyer-guide | | https://62bygw.hippovideo.io/s/XJWAro6k? W… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/houses-in-east-palo-alto-dream-homes-for-every-budget | | https://youtu.be/xkMTxjo70pA | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/menlo-park-houses-for-sale-monthly-market-update | | https://youtu.be/xpuyGJiAriM | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-homes-for-sale-stylish-loft | | https://youtu.be/1qxzr2_GlGY | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ULo7 | | https://drive.google.com/file/d/1R8h7q1tkP… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-market-update-for-homeowners | | https://youtu.be/j9dH9J_uZx8 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-california-real-estate-trends | | https://youtu.be/D398slPWpwk | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-trends-2025-home-warranty-explained | | https://youtu.be/lg9R8VVEe24 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-inventory-trends-low-inventory-market-impact | | https://youtu.be/YqmnsBJtGto | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-market-update-feb-2025 | | https://youtu.be/UuyT46sxvfk | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-san-leandro-modern-family-home | | https://youtu.be/kVGvjcUKGmI | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-client-testimonial | | https://youtu.be/ICsCIS90AZE | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-farm-hill-7210-eagle-ridge-dr-gilroy-ca | | https://youtu.be/Bp2yY9AnP9k | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| hi.switchy.io/KII1 | | https://graehamwatts.com/redwood-city-ca-h… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/essential-home-selling-tips-biggest-seller-mistakes | | https://www.youtube.com/watch?v=t5K0mSd9vho | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-ca-homes-for-sale-buyer-tips-1765-e-bayshore-rd-204 | | https://youtu.be/288g3Z5OFO8 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/menlo-park-real-estate-market-update-latest-trends-insights | | https://youtu.be/U1qjQsQBjFw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-fix-now-pay-later | | https://youtube.com/shorts/4t-rdIeqxFo | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-redwood-city | | https://youtu.be/A92e3q7mDQg | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-forecast-shocker-haunting-2025 | | https://www.youtube.com/shorts/rVrZ9Kp_2u0 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-out-of-state-overwhelmed-help | | https://www.youtube.com/shorts/trlfRnmnw28 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-update-home-selling-fast | | https://youtube.com/shorts/uZaeOawttVE | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/rare-redwood-city-real-estate-fixer-2-bed-condo-hidden-potential | | https://youtu.be/hCYx3eGj6OQ | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-insights | | https://youtu.be/Nrmd49MtMSk | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/game-changing-real-estate-predictions-2025-big-deals-big-results | | https://youtube.com/shorts/P1YFfUbH7kk | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-mistakes-to-avoid | | https://www.youtube.com/shorts/ACcRPJE3jyU | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/best-time-to-buy-real-estate-1930-pinole-drive-tour | | https://youtube.com/shorts/hQ0_-D-afYE | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ab-1482-unlocked-secrets-every-california-landlord-should-know | | https://youtu.be/ohWO_mG0qiw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/scariest-real-estate-costume-ever | | https://www.youtube.com/shorts/2jnSAF2CLnA | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-market-update-prices-up-inventory-down | | https://youtu.be/iq2Ckq89ULA | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-alone-sequel-we-always-wanted-east-palo-alto-homes | | https://youtu.be/j4D7UP38nuw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/unlock-hidden-redwood-city-real-estate-ca-gems | | https://youtu.be/NQMWcyCHSs8 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/beat-the-market-east-palo-alto-market-update-secret-price-bump | | https://youtu.be/7Uy_68OC1cE | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-renovation-alert | | https://youtu.be/-Nfx05GPn1U | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/2842-Cornelius-Dr-Tour | | https://youtube.com/shorts/9olaiaSnfuU | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Featured-Property-menlo-park | | https://listings.graehamwatts.com/i/menlo-… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Featured-Property-Redwood-City | | https://listings.graehamwatts.com/i/redwoo… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Featured-Property-east-palo-alto | | https://listings.graehamwatts.com/i/east-p… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-homes-for-sale-1765-bayshore-rd-203 | | https://youtu.be/KiniEUC3W70 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-east-palo-alto-ca-2398-palgas-ave | | https://youtu.be/z7X3VwkBJEw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-showdown | | https://youtu.be/9bkSH4M6c34 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-2022-sell-high-find-home | | https://youtu.be/6OCyKKB75hA | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-gaillardia-way-property | | https://youtu.be/iEU8OHj9_kg | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-law-101-ca-landlord-entry-laws-explained | | https://youtu.be/y81FHaaVUUY | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ab1482-explained-avoid-landlord-mistakes | | https://youtu.be/vGJUhXQvrPM | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/client-testimonial-kevin-rebecca-bowe | | https://youtu.be/dkLUoarS5HU | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-market-update-2022-inventory-and-prices | | https://youtu.be/ClKCUlHs6DM | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-market-update-2022-sell-for-maximum-value | | https://youtu.be/0rCfTn8FN5Y | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-city-vs-county-closing-costs | | https://youtu.be/ocfb0l1DJf4 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-2025-market-shocker-revealed | | https://youtu.be/0cNdrGka8kc | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discover-stunning-homes-for-sale-east-palo-alto-ca-graeham-watts-advantage | | https://youtu.be/NA_0CMREfGQ | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-market-update-2022-home-value-increase | | https://youtu.be/0whkr64iX_U | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/fha-loans-vs-conventional-loans | | https://youtu.be/xVqmEcc4tec | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Home-Selling-Tips-Must-Know | | https://62bygw.hippovideo.io/page/graehamw… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/price-your-home-right | | https://drive.google.com/file/d/1KB0RsLVMV… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/thinking-of-selling-watch-this | | https://drive.google.com/file/d/1c7An667lT… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-worth-estimate | | https://drive.google.com/file/d/14eimqGKP7… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-why-experience-matters | | https://youtu.be/dikIkbHIQB0 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/san-mateo-real-estate-update | | https://youtu.be/u8c9mRU66A0 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-market-update | | https://youtu.be/tOEjIHN-ZfI | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-mark-dinan-vision-for-change-city-council-candidate | | https://youtu.be/Y_IG9oKuaUQ | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/tech-growth-impact-redwood-city-real-estate | | https://youtu.be/Jh9IevCn7tA | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/interest-rates-and-real-estate-market-impact | | https://youtu.be/9pILbnitgrE | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-difference | | https://youtu.be/fff0Xjxl7Zc | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-houses-14-robin-ct-tour | | https://youtu.be/wkYryOjVlcw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/unlock-redwood-city-real-estate | | https://youtu.be/msbUaLDXlw0 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-22277-hartman-drive-property-tour | | https://youtu.be/APqqzIENZbw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-redwood-city-ca-staging-secrets | | https://youtu.be/6fNHLeT8nm4 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/mt-carmel-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-mt-c… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-market-update-graeham-watts | | https://youtu.be/eNggnaSP47w | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-hidden-gems-revealed | | https://youtu.be/rpIOXWPsleg | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/mortgage-costs-redwood-city-real-estate-ca | | https://youtu.be/TcXgG5MRm7Y | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discover-affordable-homes-under-100k-across-america | | https://youtu.be/tox0H8d5k40 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-ca-real-estate-property-tax | | https://youtu.be/GVtBBvXtlvw | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ab-1482-explained-landlords-tenants-must-know | | https://youtu.be/0iQvda-Gfxo | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-redwood-city-ca | | https://graehamwatts.com/redwood-city-edge… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-monthly-market-update | | https://youtu.be/1LZRAjwdW9Y | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-forecast-foreclosure-myth-busted | | https://youtu.be/f-JzACJM6pI | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/roosevelt-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-roos… | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-east-palo-alto-ca-living-guide | | https://youtu.be/_g2hBsYVdPU | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-market-update-feb-2025 | | https://youtu.be/tO0Y55L0Wj0 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-financing | | https://youtu.be/d0piUVmCUQ8 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/essential-home-selling-tips | | https://youtu.be/grWljZdENRs | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/essential-home-selling-tips-fourth-step-offer-accepted | | https://youtu.be/JtoOgcnJr60 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-redwood-city-ca-590-hurlingame-ave-makeover | | https://youtu.be/q1b0fqiK9g4 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-3500-19th-st-san-francisco | | https://youtu.be/N3F98VFuqr0 | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-east-palo-alto-ca-2288-addison-ave | | https://youtu.be/5YFH0qcKVvQ | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-redwood-city-ca-winning-offers-underwriting-edge | | https://youtu.be/VZ1FTIOndqI | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-786-honeywood-court-pending-sale | | https://youtu.be/r5-48v4n9Xc | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-graeham-watts-2022-successes | | https://youtu.be/wesNDJR5d8o | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-186-overlook-ave-hayward-ca-virtual-tour | | https://youtu.be/uqlYv1-5owM | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-graeham-watts-client-love | | https://youtu.be/-KsvYprH3qA | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ab-1482-explained-maximum-rent-increase-limits-in-california | | https://youtu.be/a-W-1r9UIAc | 1 | 1 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/new-rent-rules-real-estate-predictions-2025-tenants-landlords | | https://youtu.be/KawgQWCbe-I | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-moving-guide-what-you-need-to-know | | https://youtu.be/oM_kYq7BSdU | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-market-update-february-2025 | | https://youtu.be/ZCtyZf01kWg | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-moving-guide | | https://youtu.be/1WjY6nKsfvg | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-make-sales-breeze-no-stress | | https://www.youtube.com/shorts/eadSSCmcgaI | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-homes-for-sale | | https://graehamwatts.com/east-palo-alto-ca… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/S9z9 | | https://graehamwatts.com/?utm_campaign=Sel… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-dream-home-tour-30-seconds | | https://www.youtube.com/shorts/LvROobK_n_w | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-hidden-secrets | | https://youtu.be/NQMWcyCHSs8 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-trends-2025-alameda-county-hidden-gem | | https://youtube.com/shorts/xCZfvG6n7J8 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/unbelievable-house-tour-market-forecast | | https://youtube.com/shorts/ZX_PDWtqi3E | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/palm-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-palm | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discover-east-palo-alto-real-estate-february-market-update | | https://youtu.be/5q7eX3Ss6HU | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-in-the-bay-area-soquel-3-bedroom | | https://youtu.be/-FAt99sn7uo | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/exciting-real-estate-market-update-jumping-housing-market | | https://www.youtube.com/shorts/_1xN3UXfWW0 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/trusted-bay-area-realtor-house-safety-guarantee | | https://www.youtube.com/shorts/SWsqZtwiuik | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-floor-installation-tips | | https://youtu.be/c-6Npb9MxMs | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/unexpected-homes-for-sale-bay-area-dream-home | | https://www.youtube.com/shorts/9YUM9fEM0tM | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/property-tax-shock-2025-real-estate-forecast | | https://www.youtube.com/shorts/zIA-VY9l7S0 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/explore-east-palo-alto-homes-graeham-watts | | https://graehamwatts.com/east-palo-alto-ca… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| hi.switchy.io/101_Graden_St | | https://graehamwatts.com/101-garden-st | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ravenswood-school-district-50m-investment-better-schools-higher-teacher-pay | | https://youtu.be/YcOrP9Lm8D4 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/top-tips-for-selling-homes-quickly-maximize-home-value | | https://www.youtube.com/watch?v=oQTZdIt8gSs | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/rising-interest-rates-and-real-estate-prices | | https://youtu.be/3ZRiArz0EYE | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-east-palo-alto | | https://graehamwatts.com/east-palo-alto-ca… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/buy-a-home-east-palo-alto | | https://graehamwatts.com/east-palo-alto-ca… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-village-homes-for-sale-in-redwood-city | | https://graehamwatts.com/redwood-city-redw… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-market-update-january-2025-key-insights | | https://youtu.be/fo4EUuC_cVQ | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-hartman-drive-los-altos-ca-virtual-tour | | https://youtu.be/p-PRVqU6jQ4 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-secrets-unveiled | | https://www.youtube.com/shorts/YBXqxOZdbxk | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-next-real-estate-goldmine | | https://youtu.be/iiVNOLbYzKs | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-ca-homes-for-sale-house-tour | | https://youtube.com/shorts/8F7y4FAHitA | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-menlo-park-ca-modern-home-tour-1318-hollyburne-avenue | | https://youtu.be/dKct3RK1evQ | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/graeham-watts-east-palo-alto-listings | | https://graehamwatts.com/east-palo-alto-ca… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-trends-2025-phone-to-realty | | https://youtube.com/shorts/qbK4pNsIsdo | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discover-homes-for-sale-east-palo-alto-ca-1560-kavanaugh-drive-tour | | https://youtu.be/u04SeMhpk5g | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/home-selling-tips-busting-3-buyer-myths | | https://youtu.be/I5cgy1ck4Gc | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/the-buzz-in-redwood-city-real-estate | | https://youtu.be/MAxSNYhAteM | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ab1482-what-landlords-need-to-know-and-do | | https://youtu.be/O_w34dqEt5Y | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/eagle-hill-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-eagl… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-homes-for-sale-graeham-watts | | https://graehamwatts.com/east-menlo-park?u… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| hi.switchy.io/epa-comps-0601 | postcard,qr,consumer,epa_06_01_26 | https://graehamwatts.com/value?utm_source=… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discover-stunning-homes-for-sale-menlo-park | | https://youtu.be/jvp54Jb7VK4 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-homes-for-sale-listings-graeham-watts | | https://graehamwatts.com/east-menlo-park?u… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/expert-home-selling-tips | | https://youtu.be/rqMMBnK9d8E | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-realtor-free-staging-and-repairs | | https://youtu.be/1lqbMWIOnW8 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ab-1482-rent-increases-exemptions-and-california-rental-laws-explained | | https://youtu.be/b2xwhEpEBu8 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Ace_Your_First_Home_Purchase | | https://62bygw.hippovideo.io/s/PzyGv5bk? W… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/homes-for-sale-menlo-park-ca-update | | https://youtu.be/bWzXuOnZJGk | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/bay-area-home-search-map | | https://listings.graehamwatts.com/idx/map/ | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/downtown-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-down… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/maximize-your-home-sale-value | | https://youtu.be/83RtQ3vqGEo | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-menlo-park-homes-for-sale | | https://graehamwatts.com/east-menlo-park?u… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-paradise | | https://youtu.be/Rm99sPa28-0 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Redwood-Oaks-homes-for-sale-in-redwood-city-ca | | https://graehamwatts.com/redwood-city-redw… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/stambaugh-heller-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-stam… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-housing-market-update | | https://youtu.be/mXjf-uXTt6g | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/essential-real-estate-market-update-homebuyers-guide | | https://62bygw.hippovideo.io/s/XozaVD9Q? W… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-housing-market-update | | https://youtu.be/0Yzkc6YSnA0 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-2025-california-landlord-risks | | https://youtu.be/DYlXSFnO-5M | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/central-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-cent… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-homes-for-sale-woodland-creek-condo | | https://www.youtube.com/watch?v=4z_EXVd2aiw | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/discovering-redwood-city-real-estate | | https://youtu.be/BHbX0AyHIzM | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/menlo-park-market-update-belle-haven-housing-trends | | https://youtu.be/Nm6-hBJZBwg | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-forecast-fed-interest-rates-explained | | https://youtu.be/3NY7jujU4hM | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-crucial-market-update | | https://youtu.be/j9dH9J_uZx8 | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-just-listed-1186-overlook-ave-hayward-ca-house-tour | | https://youtu.be/HDqkFGIluiQ | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-monthly-market-update | | https://youtu.be/C1m4qdLZILE | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/centennial-redwood-city-homes-for-sale | | https://graehamwatts.com/redwood-city-cent… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-trends-2025 | | https://youtu.be/CXOle49DNbk | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/kIhN | | https://graehamwatts.com/listing-detail/11… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/best-indian-restaurant-redwood-city-broadway-masala | | https://www.youtube.com/watch?v=igwB8ZVOj7Y | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Redwood-City-Homes-on-Sale-Hot-Listings-Price-Drops | | https://graehamwatts.com/redwood-city-home… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Redwood-City-Homes-on-Sale-Hot-Listings-Price-Drops-Graeham-Watts | | https://graehamwatts.com/redwood-city-home… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Redwood-City-Homes-on-Sale | | https://graehamwatts.com/redwood-city-home… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/2115-Clarke-Ave | | https://listings.graehamwatts.com/idx/deta… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/Redwood-City-Homes-on-Sale-Hot-Listings | | https://graehamwatts.com/redwood-city-home… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-san-francisco-monthly-market-update | | https://youtu.be/9N4Y58df8QE | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/essential-real-estate-market-update-guide-for-homebuyers | | https://youtu.be/0_5fp-8gxMU | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/essential-home-selling-tips-second-step-pre-qualification | | https://youtu.be/860IJbDEfVI | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/choose-the-right-agent | | https://drive.google.com/file/d/1AeAby_tOh… | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/ab-1482-explained-is-your-property-rent-controlled-or-exempt | | https://youtu.be/mxpvotOcARY | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/stunning-homes-for-sale-menlo-park-1135-madera-ave-tour | | https://youtu.be/HMIJcWF2VLo | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-spanish-style-4-bedroom-friendly-acres | | https://www.youtube.com/shorts/s5ANgnt78Yo | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-market-update | | https://youtu.be/d7vXrMHu6Xc | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-foundation-tips-when-buying-your-new-home | | https://youtu.be/Alxmfb7BHjg | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/real-estate-market-update-tips-to-protect-your-offer-and-investment | | https://youtu.be/C8QcRE9OMJk | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/east-palo-alto-real-estate-mark-dinan-vision-change | | https://www.youtube.com/shorts/05Q6-V_58jw | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/what-you-can-buy-redwood-city-houses-for-sale | | https://www.youtube.com/shorts/s2LZKuUx8Jc | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/redwood-city-real-estate-market-february-update-you-need-to-know | | https://youtu.be/uWGng3F1glA | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| pages.graehamwatts.com/selling-your-home-just-got-easier | | https://youtube.com/shorts/cTYXi2SH3gc | 0 | 0 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| **TOTAL** | | | **15,997** | **8,855** | **$1,397** | | + +**How to read this:** *Audience* = clicks that resolve to a targetable pixeled user. *Monthly budget* is what it costs to hit that audience 10x over 30 days at $22 CPM — i.e. the spend the audience can actually absorb, not a target. Audiences under 100 can't be targeted; under 1,000 should be merged by source. \ No newline at end of file diff --git a/scheduled-tasks/README.md b/scheduled-tasks/README.md new file mode 100755 index 0000000..cb8efb0 --- /dev/null +++ b/scheduled-tasks/README.md @@ -0,0 +1,20 @@ +# Scheduled Tasks + +This folder holds the canonical, version-controlled copy of Cowork scheduled-task SKILL.md files. Cowork itself reads them from `~/Documents/Claude/Scheduled//SKILL.md` on each machine, so when one of these files changes here in the repo, the next step is to copy it onto each machine that runs the task. + +## Sync onto the Mac Studio + +```bash +cd ~/Documents/Claude/Skills +git pull origin main +# Copy any updated scheduled task SKILL.md files into Cowork's scheduled folder +rsync -a scheduled-tasks/ ~/Documents/Claude/Scheduled/ +``` + +That `rsync -a` copies the contents of `scheduled-tasks//SKILL.md` into `~/Documents/Claude/Scheduled//SKILL.md` while preserving the folder structure. Run it any time after pulling. + +## Tasks tracked here + +| Task | Cadence | What it does | +|---|---|---| +| pcfs-cma-autobuild-weekly | Mon 9:21am PT | Builds past-client CMA value-update reports for clients due in next 7 days; sends review emails to Graeham + Adrian (changed from drafts → sends 2026-05-26) | diff --git a/scheduled-tasks/pcfs-cma-autobuild-weekly/SKILL.md b/scheduled-tasks/pcfs-cma-autobuild-weekly/SKILL.md new file mode 100755 index 0000000..8e255a9 --- /dev/null +++ b/scheduled-tasks/pcfs-cma-autobuild-weekly/SKILL.md @@ -0,0 +1,66 @@ +--- +name: pcfs-cma-autobuild-weekly +description: Weekly: build past-client CMA value-update reports for clients due in the next 7 days and send review emails to Graeham + Adrian (direct send — no longer drafts, because drafts get lost). +--- + +You are running the weekly PCFS CMA auto-build for Graeham Watts (REALTOR, Intero Real Estate, DRE #01466876, 650-308-4727, graehamwatts@gmail.com). This produces PAST-CLIENT home-value update CMAs and SENDS review emails directly to Graeham + Adrian. NOTHING is auto-sent to clients — you send the review email ONLY to graehamwatts@gmail.com + graehamwattsclientcare@gmail.com, and they manually forward the bottom (client-facing) section to the actual past client after reviewing. + +IMPORTANT DELIVERY CHANGE (2026-05-26): previously this task created Gmail DRAFTS. Drafts got lost in the drafts folder. We now SEND the review emails directly to Graeham + Adrian's inboxes so they show up where they'll actually be seen. Same content, same two-section format with a divider — just sent instead of drafted. + +STEP 1 — Get the due list. +Fetch the due CMAs from this n8n endpoint (7-day window): https://n8n.graehamwattsn8n.com/webhook/cma-due-list?days=7 +Try mcp__workspace__web_fetch first; if that is blocked, use Claude in Chrome (navigate a tab to that URL and read the JSON body). The response is JSON: { count, due_cmas: [ { client_name, email, property_address, due_date, contact_id, last_cma_sent } ] }. +Dedup: read the local log at the workspace path "Online Content/cma/_autobuild_log.json" (create if missing). Skip any {client_name + due_date} already logged. Only process NEW ones. +If a due_cma has a BLANK property_address, do NOT guess — add it to a "needs address" list to report to Graeham, and skip building it. + +STEP 2 — For each client to process, build the CMA. +First detect MLS login: select Graeham's Mac Studio Chrome (mcp__Claude_in_Chrome__list_connected_browsers then select the macOS 'chrome'), open a tab to https://search.mlslistings.com/Matrix/Search/Residential/ResidentialSearch?f= . If it redirects to login.aspx, MLS is LOGGED OUT. + - If LOGGED IN: Use the cma-generator skill methodology. Pull the subject specs from Realist (REALIST tab → search the address), then pull SOLD comps: same city, Single Family Home, SqFt within ~250 of subject, Close Of Escrow date in the last 6 months. Capture ~10-20 comps with sold price, $/sqft, sqft, beds/baths, lot, age, DOM. MLSListings carries East Bay via reciprocal share, so Contra Costa/Alameda addresses work too. + - If LOGGED OUT: per Graeham's instruction, build from PUBLIC data, but HARD-FLAG every figure as a lower-confidence estimate (state clearly in the report and email that MLS was unavailable and numbers should be verified). Also include in your final report-to-Graeham a note: "MLS was logged out — flagged public-data estimates used; log in for full-confidence versions." + Compute all statistics in Python (mcp__workspace__bash) for accuracy — never eyeball math. + +STEP 3 — PAST-CLIENT VERBIAGE (critical — this is an owner's value update, NOT a listing presentation). + +⚠️ MANDATORY CHECKLIST CHECK (added 2026-05-26): Before writing a single line of HTML, READ `skills/cma-generator/references/past_client_mode.md` MANDATORY CHECKLIST section in full. Every item on that checklist must appear in your published HTML — all five Chart.js charts (trendPrice, trendLS, priceJourney, domVsCut, priceDom), every comp-table column (especially Original List, # Reductions, $-cut, List-to-Sale %), the Interest Rate Environment 4-source section, the branded nav, and zero em-dashes. The May 25 autobuild outputs (Ravi Indurkar, Viduishi Jain, Narasimha Subraveti) skipped 4 of the 5 charts, the Interest Rate section, the extra comp columns, the nav bar, and were riddled with em-dashes — that pattern is what this checklist exists to prevent. If you cannot produce a checklist item from available data (e.g., MLS history isn't reachable for Original List), state that explicitly in the report rather than silently omitting the column. + +Build the report with the cma-generator's premium branded HTML (black #1A1A1A / gold #C5A55A, the graehamwatts.com nav, Chart.js charts), BUT the language must read as a friendly update to someone who already OWNS the home: + - Hero label: "HOME VALUE UPDATE". + - DO NOT include a "Pricing Strategy" section with list-below/at/above-market advice — that is seller-listing language. Instead, a section titled "WHAT YOUR HOME IS WORTH TODAY" presenting a current market-value range framed as the owner's equity/standing, not a list price. + - Replace "Conservative / Competitive / Stretch list price" labels with value-range framing like "Likely range / Most-likely value / Top of range in strong condition." + - Tone: warm, no-agenda, "as your agent I like to keep you posted on where you stand." If the purchase price/date is known, show the equity gain since they bought. + - Keep: subject summary, the market story, comparable sales tables + $/sqft chart, market data, the value range, and honest notes (condition caveats, data source). Avoid any "let's sell / let's list" push. +Run the report through the humanizer skill before finalizing. Verify the math and comp accuracy as a QC pass. + +STEP 4 — Publish. +Save the HTML as CMA_[street_number]_[street_name_underscored].html and publish to Graeham's online-content repo at paths cma/, cma-reports/, and cmas/ via the GitHub Contents API using the token in "Online Content/github-token.txt" (classic token, repo Graehamwatts/online-content). Use the browser (example.com origin) compress→chunk→decompress→PUT method since the sandbox proxy blocks api.github.com. Live URL: https://graehamwatts.github.io/online-content/cma/CMA_[address].html . Also copy the file into the local "Online Content/cma" (and cma-reports, cmas) folders. + +STEP 5 — Send the review email (Gmail) — NOT a draft. +SEND ONE Gmail message per client directly to Graeham + Adrian. Use any send action available on the Gmail MCP (mcp__69816e67-52bb-4259-b487-681f474d6ef0) — do NOT use create_draft. If only create_draft is available, use it to compose then immediately send the resulting draft so the email lands in the inboxes (not Drafts). + + to: ["graehamwatts@gmail.com","graehamwattsclientcare@gmail.com"] + subject: "[REVIEW → forward] CMA ready: [property address] — [client name]" + + Format the body with TWO clearly-separated sections divided by an obvious "delete above this line" marker: + + ━━━━━━━━━━ INTERNAL NOTE (delete this whole section before forwarding) ━━━━━━━━━━ + + 📧 FORWARD TO: [client email address] ← this is where Graeham/Adrian sends the bottom half + 👤 Client: [client full name] + 🏡 Property: [property address] + 📅 CMA due: [due_date] + 💰 Most-likely value: $[value] | Range: $[low] – $[high] + 📊 Median $/sqft: $[median] (from [N] comps) + 🔗 Live CMA: [live_url] + 🗂️ Data source: [MLS-FULL OR PUBLIC-FALLBACK — if public, lower confidence; recommend re-run when MLS is logged in] + + Quick QC notes: [any caveats — comp quality, condition flags, equity gain math, anomalies] + + ⬇️⬇️⬇️ DELETE EVERYTHING ABOVE THIS LINE BEFORE FORWARDING TO [client email] ⬇️⬇️⬇️ + ════════════════════════════════════════════════════════════════════════════════ + ⬆️⬆️⬆️ EVERYTHING BELOW IS THE FORWARD-READY CLIENT EMAIL ⬆️⬆️⬆️ + + Suggested subject: 🔥 [warm no-agenda subject — e.g. "A quick update on your [city] home" or "Here's what your home is worth right now"] + + [Body: warm past-client greeting by first name ("Hi [first name],"), 2–3 short paragraphs framing this as a no-agenda value update, the value range stated plainly, the clickable live CMA link, friendly close, Graeham's signature with DRE# and phone.] + +Provide both plain text `body` and styled `htmlBody`. In the HTML version, render the divider as a real styled
block with the "DELET \ No newline at end of file diff --git a/scripts/AUDIT_REPORT.md b/scripts/AUDIT_REPORT.md new file mode 100755 index 0000000..b50b5a0 --- /dev/null +++ b/scripts/AUDIT_REPORT.md @@ -0,0 +1,41 @@ +# Skills Audit Report +**Generated:** 2026-05-14 +**Audited folder:** C:\Users\Graeham Watts\Documents\Claude\Skills\ + +## Note on terminology +The original prompt used "DRV"; this audit treats it as **DRE** (California Department of Real Estate license number), matching the canonical field defined in `skills/shared-references/identity.json`. + +## Canonical values (from identity.json) +- **DRE:** `01466876` (correct, current) +- **Blocklisted:** `02015066` (the recurring zombie - must never appear except in documentation-exempt files) +- **Doc-exempt files** (legitimately reference the blocklisted value to enforce policy): `CLAUDE.md`, `skills/shared-references/identity.json`, `scripts/verify_brand_identity.py` + +## Skill folder inventory +- Total skill folders under `skills/`: 45 +- All have valid `SKILL.md`: yes +- Deprecated skills (per CLAUDE.md policy) present: NONE - `video-script-creation-engine`, `social-media-analyzer`, `video-prompt-builder`, `html-email`, `github-skill-sync` all already removed +- `_backup` / `_archive` / `old` / `deprecated` folders inside Skills/: none found + +## DRE occurrences scan +| File | Status | +|---|---| +| `Skills/CLAUDE.md` lines 15, 73 | DOC-EXEMPT (policy warning) | +| `Skills/skills/shared-references/identity.json` lines 27, 30, 37 | DOC-EXEMPT (blocklist + audit history) | +| All other content files | CLEAN (0 occurrences) | + +## Stray `.skill` bundles outside Skills/ +| File | Disposition | +|---|---| +| `Documents/Claude/weekly-listing-update.skill` | ZOMBIE - canonical version is at `Skills/skills/weekly-listing-update/`. Targeted for removal. | + +## Recommended canonical version per skill type +All 45 skills under `skills/` are unique; no duplicates. Each folder's own `SKILL.md` is the canonical version. + +## Cleanup actions taken in this session +- Patched DRE 02015066 -> 01466876 in: + - `skills/content-calendar/templates/main-dashboard-builder.py` + - `skills/shared-references/publishing-via-composio.md` + - `skills/watts-motion-graphics/references/standing-rules.md` + - `skills/contract-estimate-builder/SKILL.md` + - `Online Content/dashboards/attribution/2026-05-12-daily.html` +- Pushed cleaned state to `Graehamwatts/skills` main (commit `384b595`) diff --git a/scripts/claude_with_cache.py b/scripts/claude_with_cache.py new file mode 100755 index 0000000..a8a7271 --- /dev/null +++ b/scripts/claude_with_cache.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""claude_with_cache.py - Single entry point for Claude API calls with +server-side prompt caching of the Skills bundle. + +Behavior: + - On init, reads every SKILL.md from Skills/skills// and joins them + into system prompt blocks (one block per skill). + - Marks the LAST block with cache_control: {"type": "ephemeral"} so + Anthropic caches the whole skills bundle as one entry. + - Exposes send_message(user_input, model=DEFAULT_MODEL). + - Logs CACHE WRITE on first call, CACHE HIT on subsequent calls within + 5 minutes (driven by the response's cache_read_input_tokens vs. + cache_creation_input_tokens fields). + +Usage: + from claude_with_cache import send_message + text = send_message("Hello, give me a 1-line market summary.") +""" +import datetime +import json +import os +import sys +from pathlib import Path + +try: + import anthropic +except ImportError: + print("ERROR: pip install anthropic", file=sys.stderr) + sys.exit(1) + +SKILLS_DIR = Path(r"C:\Users\Graeham Watts\Documents\Claude\Skills\skills") +DEFAULT_MODEL = "claude-sonnet-4-6" + +_CLIENT = None +_SYSTEM_BLOCKS = None + + +def _load_skill_blocks(): + """Build a list of system blocks, one per skill, plus a small header + block. The last block gets cache_control.""" + blocks = [] + header = ( + "You have access to Graeham Watts's skill toolkit. Each block below " + "is one skill's SKILL.md. The blocks are immutable - they describe " + "tools available to you. Read identity.json (referenced inside the " + "skills) for canonical brand details (name, DRE, etc.) - never type " + "those values from memory." + ) + blocks.append({"type": "text", "text": header}) + + if not SKILLS_DIR.exists(): + return blocks + + for sub in sorted(SKILLS_DIR.iterdir()): + if not sub.is_dir(): + continue + sm = sub / "SKILL.md" + if not sm.exists(): + continue + try: + content = sm.read_text(encoding="utf-8") + blocks.append({"type": "text", "text": f"### Skill: {sub.name}\n\n{content}"}) + except Exception as e: + print(f"WARN: could not read {sm}: {e}", file=sys.stderr) + + # Mark the LAST block with ephemeral cache_control so the whole prefix + # is cached as a single cache entry on Anthropic's side. + if blocks: + blocks[-1]["cache_control"] = {"type": "ephemeral"} + return blocks + + +def _client(): + global _CLIENT + if _CLIENT is None: + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + raise RuntimeError("ANTHROPIC_API_KEY not set") + _CLIENT = anthropic.Anthropic(api_key=api_key) + return _CLIENT + + +def _system(): + global _SYSTEM_BLOCKS + if _SYSTEM_BLOCKS is None: + _SYSTEM_BLOCKS = _load_skill_blocks() + return _SYSTEM_BLOCKS + + +def _log_cache(usage): + """Print one of: CACHE WRITE | CACHE HIT | CACHE MISS based on usage.""" + write = getattr(usage, "cache_creation_input_tokens", 0) or 0 + hit = getattr(usage, "cache_read_input_tokens", 0) or 0 + if write and not hit: + print(f"CACHE WRITE: {write} tokens written to cache") + elif hit: + print(f"CACHE HIT: {hit} tokens read from cache (saved)") + else: + print("CACHE MISS: no cache_read / cache_creation tokens reported") + + +def send_message(user_input: str, model: str = DEFAULT_MODEL, max_tokens: int = 1024, + stream: bool = False): + """Send one message with the cached Skills system prompt. + + Returns the assistant text. For more advanced use (tools, multi-turn), + call _client() directly with _system() as the system parameter. + """ + client = _client() + sys_blocks = _system() + + resp = client.messages.create( + model=model, + max_tokens=max_tokens, + system=sys_blocks, + messages=[{"role": "user", "content": user_input}], + ) + _log_cache(resp.usage) + + # Collect text from content blocks + parts = [] + for blk in resp.content: + t = getattr(blk, "text", None) + if t: + parts.append(t) + return "".join(parts) + + +def prewarm(): + """One-shot call to write the Skills bundle into cache. Smallest possible + real request - max_tokens=1 because Anthropic requires >= 1.""" + print("Pre-warming Skills cache...") + out = send_message("Reply with the single word: OK", max_tokens=4) + print(f" prewarm output: {out.strip()[:40]}") + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "prewarm": + prewarm() + else: + msg = " ".join(sys.argv[1:]) or "Reply with the single word: OK" + print(send_message(msg)) diff --git a/scripts/cleanup_skills.py b/scripts/cleanup_skills.py new file mode 100755 index 0000000..c6baca8 --- /dev/null +++ b/scripts/cleanup_skills.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""cleanup_skills.py - Remove zombie skill files and patch wrong DRE values. + +CANONICAL_DRE is the correct value. Edit this constant if it ever changes +(should also be reflected in skills/shared-references/identity.json). + +Safe by default: +- Creates a timestamped backup of every file BEFORE deletion or modification +- Prompts for YES confirmation before any destructive operation +- Skips documentation-exempt files (CLAUDE.md, identity.json, this script's + own definition list) so the policy warnings don't get neutered +""" +import argparse +import datetime +import re +import shutil +import sys +from pathlib import Path + +# ---- Canonical values (mirror of identity.json) ----------------------------- +CANONICAL_DRE = "01466876" # The correct current DRE +BLOCKED_DRES = ["02015066"] # Values to scrub out + +# ---- Paths ------------------------------------------------------------------ +SKILLS_ROOT = Path(r"C:\Users\Graeham Watts\Documents\Claude\Skills") +BACKUP_ROOT = SKILLS_ROOT / "_backup" + +# Files that may legitimately reference blocked values (policy/enforcement) +DOC_EXEMPT = { + "CLAUDE.md", + "identity.json", + "verify_brand_identity.py", + "cleanup_skills.py", # this script itself +} + +# Zombie bundles to remove (outside the canonical Skills/ folder) +EXTERNAL_ZOMBIES = [ + Path(r"C:\Users\Graeham Watts\Documents\Claude\weekly-listing-update.skill"), +] + + +def find_dre_violations(root: Path): + """Return list of (path, line_no, line) where a blocked DRE appears + in a file that is NOT in the doc-exempt allowlist.""" + hits = [] + exts = {".md", ".txt", ".json", ".py", ".html", ".yml", ".yaml"} + for p in root.rglob("*"): + if not p.is_file() or p.suffix.lower() not in exts: + continue + if any(seg in (".git", "_backup") for seg in p.parts): + continue + if p.name in DOC_EXEMPT: + continue + try: + for i, line in enumerate(p.read_text(encoding="utf-8", errors="ignore").splitlines(), start=1): + for bad in BLOCKED_DRES: + if bad in line: + hits.append((p, i, line.rstrip(), bad)) + except Exception as e: + print(f" WARN: could not read {p}: {e}", file=sys.stderr) + return hits + + +def find_zombie_duplicates(root: Path): + """Detect duplicate skill folders. + + A skill is identified by its folder name under skills/. A 'zombie' + is a folder that matches a deprecation list OR another folder of the + same canonical name. + """ + skills_dir = root / "skills" + if not skills_dir.exists(): + return [] + deprecated_names = { + "video-script-creation-engine", + "social-media-analyzer", + "video-prompt-builder", + "html-email", + "github-skill-sync", + } + zombies = [] + for sub in skills_dir.iterdir(): + if sub.is_dir() and sub.name in deprecated_names: + zombies.append(sub) + return zombies + + +def backup_file(p: Path, ts: str): + rel = p.relative_to(SKILLS_ROOT) if str(p).startswith(str(SKILLS_ROOT)) else Path(p.name) + dest = BACKUP_ROOT / ts / rel + dest.parent.mkdir(parents=True, exist_ok=True) + if p.is_dir(): + shutil.copytree(p, dest, dirs_exist_ok=True) + else: + shutil.copy2(p, dest) + + +def patch_dre(p: Path) -> int: + """Replace any blocked DRE with the canonical one. Returns count of replacements.""" + txt = p.read_text(encoding="utf-8", errors="ignore") + n = 0 + for bad in BLOCKED_DRES: + if bad in txt: + n += txt.count(bad) + txt = txt.replace(bad, CANONICAL_DRE) + if n: + p.write_text(txt, encoding="utf-8") + return n + + +def main(): + parser = argparse.ArgumentParser(description="Clean up zombie skills and patch DRE.") + parser.add_argument("--yes", action="store_true", + help="Skip the YES prompt (only for automation)") + args = parser.parse_args() + + print("=" * 70) + print(f"SKILLS CLEANUP - {datetime.datetime.now().isoformat(timespec='seconds')}") + print(f"Canonical DRE: {CANONICAL_DRE}") + print(f"Blocked DRE(s): {', '.join(BLOCKED_DRES)}") + print("=" * 70) + + if not SKILLS_ROOT.exists(): + print(f"ERROR: {SKILLS_ROOT} does not exist.", file=sys.stderr) + sys.exit(1) + + # Stage 1: find DRE violations + print("\n[1/3] Scanning for blocked DRE values in content files...") + violations = find_dre_violations(SKILLS_ROOT) + if violations: + for p, ln, line, bad in violations: + print(f" {p.relative_to(SKILLS_ROOT)}:{ln} ({bad})") + else: + print(" No violations.") + + # Stage 2: find zombie skill folders + print("\n[2/3] Scanning for deprecated/duplicate skill folders...") + zombies = find_zombie_duplicates(SKILLS_ROOT) + if zombies: + for z in zombies: + print(f" ZOMBIE: {z}") + else: + print(" None.") + + # Stage 3: external stray bundles + print("\n[3/3] Checking for stray .skill bundles outside Skills/...") + strays = [p for p in EXTERNAL_ZOMBIES if p.exists()] + if strays: + for s in strays: + print(f" STRAY: {s}") + else: + print(" None.") + + files_to_patch = sorted({v[0] for v in violations}) + if not files_to_patch and not zombies and not strays: + print("\nNothing to do. Exiting clean.") + return + + print("\n" + "=" * 70) + print("PLANNED ACTIONS:") + print(f" - Patch DRE in {len(files_to_patch)} file(s)") + print(f" - Delete {len(zombies)} zombie skill folder(s)") + print(f" - Delete {len(strays)} stray .skill bundle(s)") + print(f" - Backup destination: {BACKUP_ROOT}") + print("=" * 70) + + if not args.yes: + ans = input("Type YES to proceed (anything else aborts): ").strip() + if ans != "YES": + print("Aborted.") + return + + ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + BACKUP_ROOT.mkdir(parents=True, exist_ok=True) + + # Backup + patch DRE files + for p in files_to_patch: + backup_file(p, ts) + n = patch_dre(p) + print(f" patched ({n}x): {p.relative_to(SKILLS_ROOT)}") + + # Backup + remove zombies + for z in zombies: + backup_file(z, ts) + shutil.rmtree(z) + print(f" removed: {z}") + + for s in strays: + backup_file(s, ts) + s.unlink() + print(f" removed: {s}") + + # Final manifest + print("\n" + "=" * 70) + print("SURVIVING SKILL MANIFEST") + print("=" * 70) + skills_dir = SKILLS_ROOT / "skills" + for sub in sorted(skills_dir.iterdir()): + if not sub.is_dir(): + continue + sm = sub / "SKILL.md" + if sm.exists(): + mtime = datetime.datetime.fromtimestamp(sm.stat().st_mtime).isoformat(timespec="seconds") + print(f" {sub.name:35s} {mtime} {sm}") + print(f"\nBackup saved to: {BACKUP_ROOT / ts}") + + +if __name__ == "__main__": + main() diff --git a/scripts/master_reset.py b/scripts/master_reset.py new file mode 100755 index 0000000..1472e60 --- /dev/null +++ b/scripts/master_reset.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""master_reset.py - One-command orchestrator. + +Runs in sequence: + 1. cleanup_skills.py (gated by YES confirmation unless --yes passed) + 2. sync_skills.py + 3. claude_with_cache.py prewarm (writes Skills bundle into Anthropic's + server-side cache so the first real user request hits cache) + +Final report prints zombie removals, sync counts, and estimated token +savings per session. +""" +import argparse +import datetime +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent + + +def run(cmd): + print(f"\n>>> {' '.join(cmd)}") + r = subprocess.run(cmd, cwd=str(HERE)) + return r.returncode + + +def estimate_token_savings(): + """Rough estimate of cached tokens. Counts ~chars/4 across all SKILL.md.""" + skills_dir = HERE.parent / "skills" + total_chars = 0 + n = 0 + if skills_dir.exists(): + for sub in skills_dir.iterdir(): + sm = sub / "SKILL.md" + if sm.exists(): + try: + total_chars += len(sm.read_text(encoding="utf-8")) + n += 1 + except Exception: + pass + tokens = total_chars // 4 + return n, tokens + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--yes", action="store_true", + help="Skip cleanup confirmation prompt") + ap.add_argument("--skip-cleanup", action="store_true") + ap.add_argument("--skip-sync", action="store_true") + ap.add_argument("--skip-prewarm", action="store_true") + args = ap.parse_args() + + print("=" * 70) + print(f"MASTER RESET - {datetime.datetime.now().isoformat(timespec='seconds')}") + print("=" * 70) + + zombie_removals = 0 + sync_summary = "skipped" + + if not args.skip_cleanup: + cleanup_cmd = [sys.executable, "cleanup_skills.py"] + if args.yes: + cleanup_cmd.append("--yes") + rc = run(cleanup_cmd) + if rc != 0: + print("Cleanup aborted - stopping.") + sys.exit(rc) + # Count what got moved into _backup as a proxy for zombie removals + backup_root = HERE.parent / "_backup" + if backup_root.exists(): + latest = sorted(backup_root.iterdir()) + if latest: + zombie_removals = sum(1 for _ in latest[-1].rglob("*") if _.is_file()) + + if not args.skip_sync: + rc = run([sys.executable, "sync_skills.py"]) + sync_summary = "ran (see sync_log.txt)" if rc == 0 else f"errors (rc={rc})" + + if not args.skip_prewarm: + rc = run([sys.executable, "claude_with_cache.py", "prewarm"]) + if rc != 0: + print("Pre-warm failed (likely missing ANTHROPIC_API_KEY).") + + n_skills, est_tokens = estimate_token_savings() + + print("\n" + "=" * 70) + print("MASTER RESET COMPLETE") + print("=" * 70) + print(f" Files moved to _backup (proxy for zombies removed): {zombie_removals}") + print(f" Sync step: {sync_summary}") + print(f" Skills bundled into cache: {n_skills}") + print(f" Estimated cached tokens per session: ~{est_tokens:,}") + print(f" Estimated savings: cache reads cost ~10% of base " + f"input tokens, so each cached session saves roughly " + f"~{int(est_tokens * 0.9):,} billable input tokens.") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_skills.py b/scripts/sync_skills.py new file mode 100755 index 0000000..77aaecb --- /dev/null +++ b/scripts/sync_skills.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""sync_skills.py - Sync local skills to the Anthropic Console + git push. + +Behavior: + 1. Iterate every SKILL.md under Skills/skills// on the canonical disk + 2. Upload or update the matching skill via the Anthropic Skills API + 3. git add + commit + push the whole Skills repo + 4. Log every action with a timestamp to sync_log.txt + 5. Print a final summary + +Auth: + - ANTHROPIC_API_KEY must be set in the environment + - For git push, the PAT lives at Skills/github-token.txt (gitignored) + +Note: The Anthropic Skills Console API surface may evolve. This script uses +the /v1/skills HTTP endpoints (PATCH/POST/GET). If your account uses a +different surface, swap the SKILLS_LIST / SKILLS_UPSERT helpers. +""" +import datetime +import os +import subprocess +import sys +from pathlib import Path + +try: + import requests +except ImportError: + print("ERROR: install requests first: pip install requests", file=sys.stderr) + sys.exit(1) + +SKILLS_ROOT = Path(r"C:\Users\Graeham Watts\Documents\Claude\Skills") +SKILLS_DIR = SKILLS_ROOT / "skills" +LOG_PATH = SKILLS_ROOT / "scripts" / "sync_log.txt" +GIT_TOKEN_FILE = SKILLS_ROOT / "github-token.txt" +GIT_REMOTE = "https://github.com/Graehamwatts/skills.git" + +ANTHROPIC_BASE = "https://api.anthropic.com" +ANTHROPIC_VERSION = "2023-06-01" + + +def log(msg: str): + ts = datetime.datetime.now().isoformat(timespec="seconds") + line = f"[{ts}] {msg}" + print(line) + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_PATH, "a", encoding="utf-8") as f: + f.write(line + "\n") + + +def api_headers(): + key = os.environ.get("ANTHROPIC_API_KEY") + if not key: + log("FATAL: ANTHROPIC_API_KEY not set") + sys.exit(2) + return { + "x-api-key": key, + "anthropic-version": ANTHROPIC_VERSION, + "content-type": "application/json", + } + + +def list_console_skills(): + r = requests.get(f"{ANTHROPIC_BASE}/v1/skills", headers=api_headers(), timeout=30) + if r.status_code == 404: + log("Skills API returned 404 - Skills surface may not be enabled on this account.") + return {} + r.raise_for_status() + data = r.json() + by_name = {} + for s in data.get("data", data.get("skills", [])): + name = s.get("name") or s.get("display_name") + if name: + by_name[name] = s + return by_name + + +def upsert_skill(name: str, content: str, existing_id: str | None): + payload = {"name": name, "display_name": name, "instructions": content} + if existing_id: + url = f"{ANTHROPIC_BASE}/v1/skills/{existing_id}" + r = requests.patch(url, headers=api_headers(), json=payload, timeout=60) + return "updated", r + url = f"{ANTHROPIC_BASE}/v1/skills" + r = requests.post(url, headers=api_headers(), json=payload, timeout=60) + return "created", r + + +def read_skill_files(): + out = [] + for sub in sorted(SKILLS_DIR.iterdir()) if SKILLS_DIR.exists() else []: + if not sub.is_dir(): + continue + sm = sub / "SKILL.md" + if sm.exists(): + try: + out.append((sub.name, sm, sm.read_text(encoding="utf-8"))) + except Exception as e: + log(f"WARN cannot read {sm}: {e}") + return out + + +def git_commit_and_push(changed_names): + try: + if not GIT_TOKEN_FILE.exists(): + log("WARN: github-token.txt not found - skipping git push") + return False + pat = GIT_TOKEN_FILE.read_text(encoding="utf-8").strip() + ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + msg = f"skill-sync: {ts} {' '.join(changed_names) if changed_names else '(no content changes)'}" + + cwd = str(SKILLS_ROOT) + subprocess.run(["git", "add", "-A"], cwd=cwd, check=True) + diff = subprocess.run(["git", "diff", "--cached", "--name-only"], + cwd=cwd, capture_output=True, text=True) + if not diff.stdout.strip(): + log("git: no changes to commit") + return True + subprocess.run(["git", "commit", "-m", msg], cwd=cwd, check=True) + push_url = f"https://{pat}@github.com/Graehamwatts/skills.git" + subprocess.run(["git", "push", push_url, "HEAD:main"], cwd=cwd, check=True) + log(f"git: pushed - {msg}") + return True + except Exception as e: + log(f"git error: {e}") + return False + + +def main(): + log("==== sync_skills.py start ====") + local = read_skill_files() + log(f"local skills found: {len(local)}") + + if not local: + log("Nothing to sync.") + return + + try: + console = list_console_skills() + log(f"console skills found: {len(console)}") + except Exception as e: + log(f"could not list console skills: {e} - aborting console sync") + console = None + + synced = created = updated = errors = 0 + changed_names = [] + + if console is not None: + for name, path, content in local: + existing = console.get(name) + existing_id = existing.get("id") if existing else None + try: + action, resp = upsert_skill(name, content, existing_id) + if resp.status_code >= 400: + log(f" ERROR {name}: {resp.status_code} {resp.text[:200]}") + errors += 1 + continue + synced += 1 + changed_names.append(name) + if action == "created": + created += 1 + else: + updated += 1 + log(f" {action}: {name}") + except Exception as e: + log(f" EXCEPTION {name}: {e}") + errors += 1 + else: + log("Skipping console upload step (API not reachable).") + + git_commit_and_push(changed_names) + + log("==== summary ====") + log(f" synced : {synced}") + log(f" created: {created}") + log(f" updated: {updated}") + log(f" errors : {errors}") + log("==== sync_skills.py end ====") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_brand_identity.py b/scripts/verify_brand_identity.py new file mode 100755 index 0000000..7984aa6 --- /dev/null +++ b/scripts/verify_brand_identity.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +verify_brand_identity.py — Brand identity tripwire. + +Reads skills/shared-references/identity.json (the single source of truth) +and audits the entire repo. Fails (exit 1) if any blocked value appears +anywhere outside the identity file itself. + +Run before every push: + python3 scripts/verify_brand_identity.py + +Or wire into a git pre-push hook for automatic enforcement: + cp scripts/verify_brand_identity.py .git/hooks/pre-push + chmod +x .git/hooks/pre-push + +Why this exists: + Prior to April 24 2026, brand identity (DRE number especially) was + duplicated across 70+ files. Each "scrub" had to find and fix every + instance — miss one and the wrong DRE leaked into outputs. Five + consecutive scrubs failed to fully eliminate the wrong DRE because + there was no enforcement layer. + + This script IS the enforcement layer. It runs in seconds and grep-fails + the entire repo against the blocklist. Future regressions get caught + at push time instead of at user-discovery time. +""" +from __future__ import annotations +import json +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + repo_root = Path(__file__).resolve().parent.parent + identity_path = repo_root / "skills" / "shared-references" / "identity.json" + + if not identity_path.exists(): + print(f"FAIL: identity source-of-truth not found at {identity_path}") + return 2 + + with open(identity_path) as f: + identity = json.load(f) + + blocked = identity.get("_blocked_values", {}) + blocklist = blocked.get("dre_blocklist", []) + correct_dre = identity["identity"]["dre"] + + print(f"Brand identity tripwire — checking {len(blocklist)} blocked values") + print(f" Source of truth: {identity_path.relative_to(repo_root)}") + print(f" Correct DRE: {correct_dre}") + print(f" Blocklist: {blocklist}") + print() + + failures = [] + + for blocked_value in blocklist: + result = subprocess.run( + ["grep", "-rln", blocked_value, "--exclude-dir=.git", "."], + cwd=repo_root, + capture_output=True, + text=True, + ) + # Allow blocked values to appear in documentation files that + # legitimately need to reference them (e.g. CLAUDE.md warns about + # the blocked DRE so future sessions know not to add it). + exempt = set(blocked.get("_documentation_exempt", ["skills/shared-references/identity.json"])) + hits = [] + for line in result.stdout.strip().splitlines(): + if not line: + continue + # Strip leading ./ from grep output and check against exemption set + normalized = line.lstrip("./") + if normalized in exempt: + continue + if line.endswith("identity.json"): # legacy fallback + continue + hits.append(line) + if hits: + failures.append((blocked_value, hits)) + + if not failures: + print("PASS: zero blocked values found in repo.") + print(f" Repo is clean against the {len(blocklist)}-item blocklist.") + return 0 + + print("FAIL: blocked values found:") + for blocked_value, hits in failures: + print(f"\n {blocked_value!r} appears in {len(hits)} file(s):") + for hit in hits: + print(f" - {hit}") + + print() + print("Fix: replace each instance with the correct DRE from identity.json,") + print(" or update identity.json's blocklist if a value is no longer blocked.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/_shared/transcription/PROPERTY_OS_SPEC.md b/skills/_shared/transcription/PROPERTY_OS_SPEC.md new file mode 100755 index 0000000..eab0c3d --- /dev/null +++ b/skills/_shared/transcription/PROPERTY_OS_SPEC.md @@ -0,0 +1,370 @@ +# Property OS — Transcription Service Specification + +**Status:** Draft v1 — May 2026 +**Owner:** Graeham Watts +**Purpose:** Spec for a transcription microservice inside Property OS so team members and end-users can drop a video URL and get a transcript back automatically. No manual paste. No browser automation. No team time spent on transcription. + +--- + +## What this is — and what it isn't + +This document is the engineering blueprint for adding transcription to Property OS as a server-side feature. It is meant to be handed to a developer (or used by Claude in a future session if Graeham provides the Property OS stack). + +**What's in scope:** +- API design (the endpoints Property OS exposes) +- Worker architecture (how transcription jobs run in the background) +- Storage schema (how transcripts are kept in the DB) +- Cost model (what this will run per month at various scales) +- Choice of transcription engine and download tool +- Failure handling + +**What's out of scope:** +- Property OS frontend UI design (depends on the existing app) +- Auth model (assumes Property OS already has user accounts) +- Specific deployment platform (works on AWS, Vercel, Railway, Render, etc.) + +--- + +## The honest scope-setting + +Claude in a Cowork session can build skills that run in Graeham's Cowork sandbox. Claude CANNOT magically run a transcription service inside Property OS for Graeham's team to use. The service has to be deployed by a developer (or by Claude in a future session if the codebase is connected). + +**What Claude can do here:** +- Write the entire service code (Python or TypeScript) ready for deployment +- Spec the database schema and migrations +- Build a working demo runnable in a sandbox +- Document everything + +**What Claude cannot do:** +- Deploy code to Property OS production infrastructure unilaterally +- Hold a long-running transcription server in a Cowork session (sessions expire) +- Maintain login state for paid services like Unmixr at scale + +The fastest realistic path: Claude writes this service in a future session, Graeham's dev (or Claude with deploy access) deploys it, team uses it via Property OS UI from then on. + +--- + +## Recommended architecture + +### Tech stack choice + +Two viable stacks depending on what Property OS already runs on: + +**Option A — Node/TypeScript stack** (if Property OS is Next.js/Express/Nest/etc.) + +- API: Next.js API route or Express endpoint +- Queue: BullMQ (Redis-backed) +- Workers: Node workers running BullMQ consumers +- Download tool: `youtube-dl-exec` (Node wrapper around yt-dlp) +- Transcription: Deepgram Node SDK +- DB: existing Property OS PostgreSQL + +**Option B — Python sidecar** (if Property OS is mostly TS but transcription wants Python) + +- API: FastAPI service running as a separate microservice +- Queue: Celery + Redis OR RQ + Redis +- Workers: Python workers in Docker +- Download tool: `yt-dlp` (native Python) +- Transcription: Deepgram Python SDK or local Whisper for low-tier +- DB: existing Property OS PostgreSQL accessed via SQLAlchemy or asyncpg + +**My recommendation:** If Property OS is Node, go Option A — keeps it in one stack. If Property OS already has a Python service (or you want local Whisper as a backup tier without Deepgram costs), go Option B. + +For most production Property OS deployments serving a team, **Option A with Deepgram-only (no local Whisper) is simpler and cheaper to operate.** Local Whisper means you need GPU-or-very-fast-CPU workers, which complicates deployment. Deepgram-only means stateless workers, simple horizontal scaling, predictable costs. + +### Service topology + +``` + ┌──────────────────┐ + │ Property OS UI │ + │ (Next.js/React) │ + └────────┬─────────┘ + │ POST /transcribe { url } + ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Property OS API │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ POST /api/transcribe │ │ +│ │ 1. Validate URL, check user has credit/quota │ │ +│ │ 2. Insert transcripts row (status=pending) │ │ +│ │ 3. Enqueue job in BullMQ → returns job_id + transcript_id │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ GET /api/transcribe/:id → returns transcript row (status + text) │ │ +│ │ GET /api/transcribe → list user's transcripts │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────┬───────────────────────────────────────────────────┘ + │ enqueue + ▼ + ┌────────────────┐ + │ Redis │ + │ (BullMQ) │ + └────────┬───────┘ + │ workers pick up + ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Transcription worker(s) — horizontally scalable │ +│ 1. Pull job (URL + transcript_id) │ +│ 2. yt-dlp downloads audio to /tmp │ +│ 3. POST audio to Deepgram │ +│ 4. Update transcripts row (status=complete, transcript_text=...) │ +│ 5. Send webhook/Pusher event to Property OS UI │ +│ 6. Optional: trigger downstream content pipeline │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +### Database schema (PostgreSQL) + +```sql +CREATE TABLE transcripts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + property_id UUID REFERENCES properties(id) ON DELETE SET NULL, -- optional context + + source_url TEXT NOT NULL, + source_platform TEXT, -- youtube | instagram | tiktok | ... + source_title TEXT, -- pulled from yt-dlp metadata + source_uploader TEXT, -- creator handle + duration_sec INTEGER, + + status TEXT NOT NULL DEFAULT 'pending', + -- pending | downloading | transcribing | complete | failed + tier TEXT NOT NULL DEFAULT 'standard', + -- standard | premium + + transcript_text TEXT, + word_count INTEGER, + + error_message TEXT, + retry_count INTEGER DEFAULT 0, + + cost_cents INTEGER, -- Deepgram cost for this job + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE INDEX idx_transcripts_user_status ON transcripts(user_id, status); +CREATE INDEX idx_transcripts_source_url ON transcripts(source_url); +CREATE INDEX idx_transcripts_status_created ON transcripts(status, created_at); + +-- Optional: dedupe across the whole platform +CREATE UNIQUE INDEX idx_transcripts_dedupe ON transcripts(source_url, tier) + WHERE status = 'complete'; +``` + +### API surface + +```typescript +// POST /api/transcribe +// Body: { url: string, tier?: 'standard' | 'premium', property_id?: string } +// Returns: { transcript_id: string, status: 'pending', estimated_seconds: number } + +// GET /api/transcribe/:id +// Returns: { id, status, source_url, source_platform, source_title, +// duration_sec, transcript_text, word_count, error_message, +// created_at, completed_at } + +// GET /api/transcribe?status=complete&limit=50 +// Returns: array of transcript rows for the authenticated user + +// DELETE /api/transcribe/:id +// Removes the transcript (user's own only) + +// POST /api/transcribe/:id/retry +// Re-runs a failed job +``` + +### Frontend integration + +```typescript +// In Property OS UI: +async function submitTranscription(url: string, tier: 'standard' | 'premium' = 'standard') { + const res = await fetch('/api/transcribe', { + method: 'POST', + body: JSON.stringify({ url, tier }), + }); + const { transcript_id } = await res.json(); + + // Poll OR subscribe to Pusher channel for status updates + // (Pusher recommended — Property OS likely already uses it) + + return transcript_id; +} +``` + +Real-time updates: use Pusher / Ably / Supabase Realtime to push status changes to the UI without polling. The worker updates the row, and a Postgres NOTIFY or a Pusher event fires the UI update. + +--- + +## Cost model + +### Deepgram Nova-3 (recommended) + +- **Standard plan:** $0.0043 per minute of audio +- **Growth plan:** $0.0036 per minute (at higher volume) +- **Pre-paid bulk:** Volume discounts available — talk to sales at scale + +### Real-world costs at various team scales + +Assumption: average video length 5 minutes. + +| Scale | Videos/month | Minutes/month | Cost/month | +|---|---|---|---| +| Solo agent | 20 | 100 | $0.43 | +| Small team (5 agents) | 100 | 500 | $2.15 | +| Property OS public beta (50 users, 20 videos each) | 1,000 | 5,000 | $21.50 | +| Property OS scale (500 users, 20 videos each) | 10,000 | 50,000 | $215 | + +Deepgram costs are negligible relative to the team time saved. A single agent spending 10 min/day on manual transcription costs the business ~$50/day in opportunity cost. Deepgram saves that for cents. + +### Infrastructure costs + +- **Redis (Upstash or similar managed):** Free tier covers up to ~10K commands/day; ~$10/month for low-mid scale; $50-100/month at scale +- **Worker compute (Render / Railway / Fly.io background worker):** ~$10/month for a small worker; ~$50-100/month for 3-5 workers at scale +- **Storage:** Negligible — transcripts are text, ~5KB per row average + +**Total estimated cost at the Property OS-scale tier:** ~$300/month all-in for transcription serving 500 active users. At small-team scale: under $20/month. + +--- + +## yt-dlp robustness — the Instagram problem + +The hard part isn't transcription. Deepgram is rock solid. The hard part is **reliably extracting audio from platforms that fight scrapers**, mainly Instagram. + +**yt-dlp success rates (rough industry estimates, 2026):** + +- YouTube: 98%+ (Google rarely breaks yt-dlp; even when they do, fix lands in days) +- TikTok: 95%+ (occasional rate limits) +- Vimeo: 99%+ +- X / Twitter: 90% (depends on tweet visibility) +- Facebook: 85% +- **Instagram: 70-85%** (Instagram aggressively breaks yt-dlp; rate limits hit fast) + +For production reliability on Instagram, **use Apify's Instagram Reel Scraper** as a fallback: + +- ~$0.50 per 1000 reels +- Maintained by Apify's team — they keep up with Instagram changes +- Returns audio URL + metadata +- Hand off audio URL to Deepgram + +**Hybrid strategy:** +1. Try yt-dlp first (free) +2. If it fails on Instagram, retry via Apify (paid but reliable) +3. Log every fallback so you can monitor yt-dlp reliability over time + +### Worker pseudocode + +```typescript +async function processTranscriptionJob(job: TranscriptionJob) { + const { url, transcript_id, tier } = job.data; + + await db.update('transcripts', { id: transcript_id, status: 'downloading' }); + + let audioPath: string; + try { + audioPath = await downloadWithYtDlp(url); + } catch (err) { + // Instagram fallback + if (url.includes('instagram.com')) { + audioPath = await downloadWithApify(url); + } else { + await markFailed(transcript_id, err); + return; + } + } + + await db.update('transcripts', { id: transcript_id, status: 'transcribing' }); + + let transcript: string; + if (tier === 'premium') { + transcript = await deepgramTranscribe(audioPath, 'nova-3'); + } else { + transcript = await deepgramTranscribe(audioPath, 'nova-2'); + // Note: even "standard" tier uses Deepgram in this Property OS spec — local Whisper + // is the right move for Cowork sessions but adds operational complexity in production + } + + const wordCount = transcript.split(/\s+/).length; + const costCents = Math.ceil(durationSec / 60 * 0.43); // standard rate + + await db.update('transcripts', { + id: transcript_id, + status: 'complete', + transcript_text: transcript, + word_count: wordCount, + cost_cents: costCents, + completed_at: new Date(), + }); + + await pusher.trigger(`user-${userId}`, 'transcript-complete', { transcript_id }); +} +``` + +--- + +## Why NOT just use Cowork sessions for the team + +Graeham's instinct is the right one — building this into Property OS server-side is better than having every team member run Cowork sessions. Reasons: + +1. **Cowork sessions are interactive.** They're great for Graeham working on content, not great for "the system does it while I sleep." +2. **Session state is per-user.** Each team member would need their own Cowork session, their own credentials, their own setup. +3. **Sessions time out.** A team member starts a transcription, walks away, comes back to a closed session. +4. **No shared transcript history.** Cowork sessions don't share state — Property OS DB shares state across the team naturally. +5. **No automation.** Property OS can trigger transcription on events (new lead, new listing, scheduled batch). Cowork only fires when a human is there. + +Production transcription = backend service. End of story. + +--- + +## Suggested implementation order + +If Graeham wants this built (in a future Cowork session or by his dev), here's the order: + +1. **Week 1 — Foundation** + - Set up Redis + BullMQ + - Implement the `POST /api/transcribe` endpoint + - Create the `transcripts` DB table + migrations + - Stub worker that just downloads and logs (no transcription yet) + +2. **Week 2 — Core transcription** + - Integrate Deepgram SDK in the worker + - Implement yt-dlp download + - Wire up status updates (Pusher or polling) + - Build the minimal UI: "Drop URL → see transcript when done" + +3. **Week 3 — Reliability & polish** + - Add Apify fallback for Instagram + - Build retry logic for failed jobs + - Add cost tracking + per-user quotas + - Add transcript search in Property OS + +4. **Week 4 — Integration with content pipeline** + - Wire completed transcripts into Property OS content workflow + - Add bulk-import (drop 10 URLs, get 10 transcripts) + - Add scheduled batch jobs (transcribe a creator's whole feed weekly) + +Total: roughly 4 weeks of focused dev work for a small team, or ~2 weeks if it's the dev's primary focus. + +--- + +## Open questions for Graeham + +Before building, decide: + +1. **What stack is Property OS on?** Node/TypeScript or Python? This determines Option A vs Option B. +2. **What's the hosting platform?** Vercel, AWS, Railway, Render, Fly.io? Each has different worker support. +3. **Who pays for Deepgram — Property OS or end-user?** Per-user quotas? Per-team plans? +4. **Do you want speaker diarization?** ("Who said what" in podcasts.) If yes, AssemblyAI is better than Deepgram. Costs a bit more. +5. **Do you want this to feed into Graeham's existing content pipeline automatically?** E.g., transcript completes → auto-runs through `transcript-repurposer` logic → content draft saved. + +Once those are answered, the build is straightforward. + +--- + +## Bottom line + +Build the service. Don't try to automate Unmixr or scale Cowork sessions for the team. Deepgram + yt-dlp + a simple worker is the right architecture, costs almost nothing at small scale, and runs hands-off forever. + +The transcription module in `_shared/transcription/transcribe.py` is the reference implementation. The Property OS version is the same logic, deployed as a server-side service with a queue. diff --git a/skills/_shared/transcription/README.md b/skills/_shared/transcription/README.md new file mode 100755 index 0000000..4e03f6d --- /dev/null +++ b/skills/_shared/transcription/README.md @@ -0,0 +1,125 @@ +# Shared Transcription Module + +Single source of truth for video → text across all of Graeham's skills. + +## Why this exists + +Before this module: `content-creation-engine` had its own YouTube transcriber, the new `transcript-repurposer` was about to grow its own, and Property OS was on track to grow a third. Three different transcribers means three different sets of bugs, three different output formats, three different failure modes. + +This module is the one place where transcription happens. Any skill that needs a transcript calls `transcribe.py` and gets back a consistent JSON result. + +## Two tiers + +| Tier | Engine | Cost | Speed | Accuracy | Trigger | +|---|---|---|---|---|---| +| Default | yt-dlp + OpenAI Whisper (local in sandbox) | Free | 30s-5min depending on length | ~95% | Default — no flag needed | +| Premium | yt-dlp + Deepgram Nova-3 API | $0.0043/min | ~real-time for short, ~10x for long | 98%+ | `--premium` flag + `DEEPGRAM_API_KEY` env var | + +## Usage + +From any skill (or the command line): + +```bash +# Default: Whisper local, free +python3 transcribe.py --url "https://www.instagram.com/reel/..." + +# Premium: Deepgram, higher accuracy +DEEPGRAM_API_KEY=your_key python3 transcribe.py --url "..." --premium + +# Local audio file +python3 transcribe.py --file /path/to/podcast.mp3 + +# Get the full JSON result (default outputs transcript text only) +python3 transcribe.py --url "..." --json + +# Write to a file +python3 transcribe.py --url "..." --output transcript.txt +``` + +## Caching + +Every successful transcription is cached under `~/.cache/graeham-transcripts/.json`. Same URL + tier = same cache key. Re-running on a cached URL returns instantly. + +Cache key hashes the source + tier — so the same URL transcribed via Whisper vs Deepgram are stored separately. Useful when you want to compare quality or upgrade a previous Whisper transcript to Deepgram. + +## Supported sources + +Anything yt-dlp supports — that's 1,000+ sites including: + +- YouTube (videos, Shorts, live streams) +- Instagram (Reels, posts, IGTV) +- TikTok +- Twitter / X video posts +- Facebook video +- Vimeo +- Twitch VODs +- Most podcast hosting platforms +- Direct .mp3 / .mp4 / .m4a file URLs + +For full list: `yt-dlp --list-extractors`. + +## Output JSON shape + +```json +{ + "transcript": "Clean spoken text...", + "source_url": "https://...", + "source_platform": "youtube|instagram|tiktok|x|vimeo|facebook|unknown", + "title": "

Diagram — How We Built This (10-Step Data Pipeline)

Click any node for the full details on what ran, what was found, and what was decided. 3 layers: data inputs → intelligence → outputs.

+

How the data inputs interlay into the intelligence layer

+

Each data input feeds DIFFERENT scoring criteria. Read down the columns to see what each input contributes; read across the rows to see how each intelligence step builds its score.

+
+ + + + + + + + + + + + + + + + + + + + +
 1. Topic History2. Composio YT+IG3. Apify Reddit4. WebSearch
5. BOFU Intent Scoring
5 criteria + freshness
Freshness +/-5
blocked angles, boosted pillars
P Performance
top historical post engagement
WOULD: A Audience Intent
multi-source Reddit signal
S Search Demand
A Audience Intent
C Comp Gap
T Timeliness
6. Impact + Ease
2 scores 0-100 each
Time Freshness (Impact)
+/-10 adj
Multi-Format Yield
cross-platform fit
WOULD: Audience Intent strengthMacro Alignment
Competitor Gap
market posture + competitor scan
7. Funnel Tag
day-of-week + TOFU/MOFU/BOFU
(no direct input)(no direct input)(no direct input)Urgency window
news dates set publish order
+
+

Reading the matrix: Step 5 takes ALL 4 data inputs (Topic History for freshness, Composio for performance, Reddit for intent, WebSearch for source confirmation + timing). Step 6 derives from a SUBSET (Composio + WebSearch + Topic History; Reddit unavailable this run). Step 7 sequences topics by urgency from WebSearch news dates. The Apify blocker affected Step 5 Criterion A but WebSearch compensated.

+

Calendar — Week of May 11–17

5 days. Each day = 1 topic across all platforms. Click any day to see the full data trail, scoring breakdown, why-targeted reasoning, and video + blog summaries.

📊 Why 20/20/60 this week: The user specified a 20/30/50 lead-gen push mix at the start of this run. Actual mix landed at 20/20/60 -- BOFU rounded up 10 points because of the Meta May 20 layoff window (only 8 days from publish to layoff effective date, maximum trigger-event urgency). 1 TOFU on Friday for weekend social reach. 1 MOFU on Tuesday primes the BOFU stack mid-week. 3 BOFU days front-loaded on Mon/Wed/Thu so the trigger-event content lands within the urgency window.
TOFU 20%
MOFU 20%
BOFU 60%

Video Content — All 5 Topics

Each topic has 2 copy-to-clipboard buttons, both built to paste into Claude.ai. 1 · Copy Script + Voice Prompt → Claude verifies the figures, writes the word-for-word script first (range language, no stale hard numbers), then outputs the matching ElevenLabs SSML you paste into ElevenLabs v3. 2 · Copy Production Assets → Claude generates the Editing Notes, AI Video Prompts, caption + hashtags, GHL CTA, and Fair Housing scan. The script lives in button 1, not here.

+
+Monday · May 12 +BOFU +GHL: OPTIONS +
+

Meta’s May 20 Layoffs: The 4-Step Plan If You’re a Bay Area Homeowner Who Just Got Cut

+

8-10 min YT Long: 4-step framework (don’t list yet, pull equity, don’t drain 401k, two pros in 30 days). Plus YT Short, IG Reel x2, Carousel, TikTok.

+
+ + +
+
+
+Tuesday · May 13 +MOFU +GHL: NUMBERS +
+

Bay Area Mortgage Rates Just Hit Mid-6%: The 5 Numbers You Should Re-Run This Week

+

6-8 min YT Long: 5 numbers to re-run (buying power, rent-vs-buy, refi, DTI, equity).

+
+ + +
+
+
+Wednesday · May 14 +BOFU +GHL: 1482 +
+

AB 1482 in 2026: Why Your Bay Area Rent Cap Is 6.3% — and the Single-Family Loophole Most Landlords Miss

+

7-9 min YT Long: formula (5% + 1.3% CPI = 6.3%), stricter local caps (Oakland 0.8%, Berkeley 1.0%), SFH exemption Civil Code §1946.2(e)(8)(B)(i).

+
+ + +
+
+
+Thursday · May 15 +BOFU +GHL: BUY +
+

Why Bay Area Buyers Are Skipping Palo Alto in 2026 — Where They’re Going Instead

+

8-10 min YT Long: 3 mid-Peninsula micro-markets (RWC Mt. Carmel $1.6-1.9M, EPA Westside $1.2-1.55M, San Carlos White Oaks $1.7-2.2M). Fair Housing guardrails enforced.

+
+ + +
+
+
+Friday · May 16 +TOFU +GHL: WATCH (short) → BUY (long) +
+

What $1.5M Actually Buys in Redwood City vs. Menlo Park vs. Palo Alto Right Now (Spring 2026)

+

10-12 min YT Tour: RWC Friendly Acres $1.495M (move-in, $964/sqft), MP Linfield Oaks $1.549M ($200K reno, $1,312/sqft), PA Greenmeadow $1.495M ($300-400K reno, $1,206/sqft).

+
+ + +
+

Blog Content — All 5 Topics

Each topic has 2 copy-to-clipboard buttons. Copy Blog Brief → the targeting context for Search Atlas. Copy Production Prompt → paste into Claude.ai to generate the full 1,100-1,500 word post with schema, FAQ, and image placeholders.

+
+Monday · May 12 +BOFU +GHL: OPTIONS +
+

Meta’s May 20 Layoffs: The 4-Step Plan If You’re a Bay Area Homeowner Who Just Got Cut

+

1,100 words at /blog/meta-layoffs-bay-area-homeowner-plan-2026. Article + FAQPage schema. 5 AEO cite-ready statements.

+
+ + +
+
+
+Tuesday · May 13 +MOFU +GHL: NUMBERS +
+

Bay Area Mortgage Rates Just Hit Mid-6%: The 5 Numbers You Should Re-Run This Week

+

1,200 words at /blog/bay-area-mortgage-rates-may-2026-five-numbers. Article + HowTo + FAQPage.

+
+ + +
+
+
+Wednesday · May 14 +BOFU +GHL: 1482 +
+

AB 1482 in 2026: Why Your Bay Area Rent Cap Is 6.3% — and the Single-Family Loophole Most Landlords Miss

+

1,400 words at /blog/ab-1482-bay-area-2026-rent-cap-guide. Article + FAQPage + HowTo. SEO target: "ab 1482 2026", "bay area rent cap 2026".

+
+ + +
+
+
+Thursday · May 15 +BOFU +GHL: BUY +
+

Why Bay Area Buyers Are Skipping Palo Alto in 2026 — Where They’re Going Instead

+

1,500 words at /blog/peninsula-alternatives-to-palo-alto-2026. Article + ItemList. Property-only (no schools/demographics).

+
+ + +
+
+
+Friday · May 16 +TOFU +GHL: WATCH (short) → BUY (long) +
+

What $1.5M Actually Buys in Redwood City vs. Menlo Park vs. Palo Alto Right Now (Spring 2026)

+

1,300 words at /blog/1-5-million-peninsula-comparison-spring-2026. Article + Product x3.

+
+ + +
+
+ + + \ No newline at end of file diff --git a/skills/content-creation-engine/templates/prompts-library-builder.py b/skills/content-creation-engine/templates/prompts-library-builder.py new file mode 100755 index 0000000..7656bcd --- /dev/null +++ b/skills/content-creation-engine/templates/prompts-library-builder.py @@ -0,0 +1,542 @@ +#!/usr/bin/env python3 +"""Build EPA Two Years Homicide-Free production dashboard v2. +Fixes: (1) HTML comment escape bug (no bash heredoc), (2) PROMPT_LIBRARY pattern, +(3) color correction (gold only for RE brand moments), (4) +3 missing prompts.""" +import json +from pathlib import Path + +PREAMBLE = """AGENT IDENTITY: +You are writing content AS Graeham Watts - REALTOR at Intero Real Estate, DRE# 01466876. Primary market: East Palo Alto. Secondary markets: Redwood City, Palo Alto, Menlo Park, San Mateo County, Peninsula. + +FAIR HOUSING GUARDRAILS (NON-NEGOTIABLE): +- NO demographic descriptors (race, religion, national origin, family status, disability) +- NO "safe / good areas / family-friendly / up-and-coming" as proxy for demographic signaling +- NO school rankings as a primary selling point +- NO kickback arrangements with lenders, inspectors, or vendors +Neighborhood content is LIMITED to: property features, price ranges, market trends, lot sizes, amenities, architecture, housing stock age, HOA, zoning, new development, commute/transit facts, walkability. Public safety content is permitted when framed as statistics + public policy shifts (never as neighborhood character proxy). + +DATE & YEAR QUALITY CONTROL (SELF-CHECK BEFORE EMITTING): +- Current production date: April 2026 (week of April 20-26, 2026). +- Every year reference MUST be 2026 unless explicitly historical (1992 baseline). +- Every text overlay/caption/graphic MUST use 2026 when a year is shown. +- Every cite-ready / AEO statement MUST open with a date anchor: "As of April 2026..." or "As of April 17, 2026..." +- Price and market stats MUST be dated: "As of April 2026, EPA median is $1.1M". +- Self-scan for bare year numbers and fix year-drift before emitting. + +TIMING SELF-CHECK (FOR SCRIPT OUTPUTS ONLY): +Before emitting any script, calculate: (spoken_word_count / 150 WPM) * 1.15 = target_minutes. Show the math in the output. NEVER default to generic durations like "8-10 min". + +VOICE & STYLE: +- First-person, conversational, direct (Graeham's voice) +- Specific numbers (prices, dates, percentages, addresses) +- No hype language ("amazing", "best ever", "must-see", "incredible deal") +- Open cold - hook lands in first 3 seconds, NO "hey guys welcome back" intros + +TOPIC: East Palo Alto Marks 2 Years Without a Homicide - Peninsula Buyer Narrative Reset +SLUG: epa-two-years-homicide-free +FUNNEL TIER: MOFU->BOFU (narrative education that rewrites buyer hesitation and drives to lead gen) +MARKET: East Palo Alto (primary). Peninsula comparisons required. +GHL KEYWORD: EPA +LEAD MAGNET: April 2026 East Palo Alto MLS Market Report (neighborhood-by-neighborhood PDF) + +AEO FOUNDATION (cite-ready statements to build around): +1. "As of April 17, 2026, the City of East Palo Alto, California, officially marked two full years without a homicide, with the last homicide recorded in April 2024." +2. "As of April 2026, East Palo Alto homes are selling in 32 days on average, down from 66 days year-over-year, with a median sale price increase of 1.7% YoY." +3. "As of April 2026, San Mateo County overall home prices are down 7.2% year-over-year, while East Palo Alto specifically is up 1.7% - Peninsula fragmented into micro-markets." +4. "In 1992, East Palo Alto had 42 homicides in a population of 24,000, highest per capita murder rate in the US that year." + +KEY FACTS: +- Milestone: April 17, 2026 - 2 years homicide-free +- Last homicide: April 2024 +- 1992: 42 homicides, 24K pop, US leader per capita +- EPA: +1.7% YoY, DOM 66->32 +- SMC: -7.2% YoY +- Drivers: community partnerships, youth/workforce development, modernized policing, neighborhood-department integration +- Peninsula: SF +7.7%, Palo Alto steady $3.5M +- Mortgage rates: 6.46% (Freddie Mac) + +SOURCES: Local News Matters (Apr 17 2026), The Almanac (Apr 17 2026), City of East Palo Alto, Redfin, Benson Group, Own Team, Palo Alto Online. + +GHL LEAD CAPTURE CTA: +"Comment 'EPA' below and I'll send you the April 2026 East Palo Alto MLS market report - neighborhood by neighborhood, pulled straight from MLS. Zero fluff, zero pressure." +""" + +PROMPTS = {} + +PROMPTS["yt-long-pt1"] = PREAMBLE + """ +DELIVERABLES - YouTube Long, Part 1 (Script + SSML): +1. FULL TIMESTAMPED SCRIPT (~4:30, 550-600 words, 6-act structure: Hook/1992 setup/Silent change/Milestone/Market angle/CTA). Inline shot tags: [TALKING HEAD], [B-ROLL: desc], [TEXT OVERLAY: "text"], [TRANSITION: type]. Slow the pace at the milestone reveal. End with GHL CTA. +2. COMPLETE ELEVENLABS SSML BLOCK. Full script in .... for pauses. on key phrases. At milestone: two full years without a homicide. Clean SSML only - no markdown fences. +OUTPUT FORMAT: Visual dividers between sections. +""" + +PROMPTS["yt-long-pt2"] = PREAMBLE + """ +DELIVERABLES - YouTube Long, Part 2 (Production Package): +(Script generated in Pt 1 - do not repeat.) +1. EDITING NOTES FOR JASON: B-roll list, text overlay timing (timestamp->text->duration), pacing notes, thumbnail concept (split: 1992 headline + modern EPA sunrise, "EPA. 2 YEARS ZERO HOMICIDES." bold white w/ red underline, subtext "And nobody reported it."), music/SFX direction. +2. AI VIDEO PROMPTS (Seedance 2.0/Kling) - minimum 3: hook opener, 1992 archival substitute, milestone reveal. Each: SHOT, PROMPT, CAMERA, LIGHTING, DURATION, USE IN EDIT. +3. YOUTUBE SEO PACKAGE: Primary title (<70 char), 2 A/B alt titles, description (first 3 lines critical), 10-15 target keywords, 15-20 hashtags. +4. 3 ALTERNATE HOOKS (A/B): Story-led, Buyer-math-led, Counter-narrative-led. Recommend which to use primary. +OUTPUT: Visual dividers between deliverables. +""" + +PROMPTS["production-brief"] = PREAMBLE + """ +DELIVERABLE - Production Brief for Peter + John (crew) and Jason (editor): +Single printable document. ONE doc, everything they need. No back-and-forth. + +OUTPUT 7 BLOCKS: +1. TIMING SUMMARY: target ~4:30, 573 words, 150 WPM, formula shown. +2. CALL SHEET: locations+addresses, shoot time (golden hour/midday), wardrobe for Graeham, equipment checklist (camera/lens/mic/lighting/drone), estimated shoot duration. +3. FULL SHOT LIST (12 numbered shots, duration, setup notes - table format). +4. B-ROLL SHOT LIST: stock/archival needs w/ license notes, original clips to shoot locally, AI-generation fallbacks. +5. EDITING NOTES FOR JASON: text overlay timing table (timestamp->text->duration), pacing notes per act, thumbnail concept (detailed), music direction per section, SFX placements. +6. AI VIDEO PROMPTS (3+, Seedance 2.0 format): each w/ SHOT, PROMPT, CAMERA, LIGHTING, DURATION, USE IN EDIT. +7. EXPORT + DELIVERY SPECS: Master (16:9 1080p H.264 for YT Long), vertical cut (9:16 1080p w/ crop timestamps), thumbnail (1280x720 JPG), naming convention (epa-two-years-homicide-free-v1-master.mp4). + +Format as printable doc the crew takes to set. No fluff. +""" + +PROMPTS["yt-short"] = PREAMBLE + """ +DELIVERABLE - YouTube Short (vertical, ~30s): +- 30-33s (70-75 words), 9:16 1080p +- Cut from long-form: 0:00-0:20 + 2:55-3:20 + 4:00-4:15 +- Structure: Hook (0-5s) -> B-roll stat break (5-9s) -> Data reveal (9-18s) -> Payoff (18-27s) -> CTA (27-33s) +- Front-weight hook. Strongest line at frame 1. Burn captions. +OUTPUT: Timestamped script w/ inline shot tags. Shorts description w/ GHL CTA. +""" + +PROMPTS["ig-reel-1"] = PREAMBLE + """ +DELIVERABLE - Instagram Reel #1 (Hook-Led, ~30s): +- 30s, 9:16, burned captions, stat overlays +- Structure: Hook (0-5s) -> 1992 B-roll (5-9s) -> 2026 reveal + data (9-18s) -> Payoff (18-27s) -> CTA (27-30s) +- Original voiceover (story needs real voice, not trending audio) +OUTPUT: 1) Timestamped script w/ shot tags, 2) IG caption w/ GHL CTA + 15-20 hashtags, 3) Optional pinned first-comment w/ cite-ready stat. +""" + +PROMPTS["ig-reel-2"] = PREAMBLE + """ +DELIVERABLE - Instagram Reel #2 (Data-Led, ~20s): +- 20s, 9:16, B-roll heavy w/ animated stat cards +- Lead w/ DATA not 1992 headline (different angle from Reel #1) +- Structure: Aerial open (0-4s) -> Stat cards cycling (4-10s) -> TH insight (10-16s) -> CTA overlay (16-20s) +- Hook: "The Peninsula isn't one market." +OUTPUT: Timestamped script w/ shot tags + stat card specs. Caption (data-forward) + hashtag set. +""" + +PROMPTS["ig-carousel"] = PREAMBLE + """ +DELIVERABLE - Instagram Carousel (8 slides, 4:5): +- Optimized for saves (reference) + shares +- Arc: Hook -> 1992 stat -> The Shift -> What Changed -> The Milestone -> Market Impact -> The Argument -> CTA +OUTPUT: 1) Content for 8 slides (title + 30-50 word body each), 2) Design direction per slide (bg color/imagery, key stat emphasis, typography hierarchy), 3) Caption (the "why swipe" hook, GHL CTA, hashtags). Slide 5 = HERO visual. +""" + +PROMPTS["tiktok"] = PREAMBLE + """ +DELIVERABLE - TikTok (~30s, casual): +- 30s, 9:16, TikTok-native tone ("Ok Bay Area TikTok...") +- Quick cuts (faster than IG Reels pacing) +- Default original audio (gravity of 1992 context); trending audio only if it doesn't undermine +OUTPUT: TikTok script w/ cut markers + shot tags. Caption (shorter than IG, GHL CTA). TikTok-optimized hashtags (#POV, #BayAreaRealEstate, #RealEstateTikTok, location tags). If recommending trending audio, name genre/mood + explain why. +""" + +PROMPTS["blog"] = PREAMBLE + """ +DELIVERABLE - Blog Post (1000-1200 words, SEO + AEO): +- URL: graehamwatts.com/blog/epa-two-years-homicide-free-april-2026 +- H1->H2->H3 semantic structure. Each section: 1+ cite-ready declarative statement. +- SEO keywords (organic): east palo alto real estate, east palo alto homes, peninsula real estate 2026, epa market update, epa home values +OUTPUT: 1) Title tag (<60 char), 2) Meta description (<155 char), 3) H1 headline, 4) Full body 1000-1200w w/ 6-section structure (Hook/Numbers/What Changed/Buyer Meaning/Owner Meaning/CTA), 5) 3 FAQ entries (FAQPage structured data), 6) Internal link suggestions (2-3), 7) Sources section w/ clickable citations. Graeham voice. No hype. +""" + +PROMPTS["gmb"] = PREAMBLE + """ +DELIVERABLE - Google My Business Update Post (~250 words): +- 200-300 words (GMB limit 1500 char) +- "East Palo Alto" MUST be in first sentence (local SEO) +- CTA button "Learn More" -> blog post +OUTPUT: 1) GMB post body (250w - local hook, 3 stat bullets, micro-market framing, soft CTA, sign-off w/ DRE), 2) CTA button label + target URL, 3) Suggested image direction. +""" + +PROMPTS["facebook"] = PREAMBLE + """ +DELIVERABLE - Facebook Post (extended caption, cross-post Reel): +- 200-400 words (FB favors longer than IG) +- Cross-post primary Reel. YouTube link in body. +- FB audience skews older/homeowners - slightly more professional tone +OUTPUT: 1) Facebook post body 200-400w w/ paragraph breaks, 2) Suggested post type (video cross-post w/ native caption), 3) First comment w/ pinned YouTube link + cite-ready stat. +""" + +PROMPTS["linkedin"] = PREAMBLE + """ +DELIVERABLE - LinkedIn Post (professional): +- 300-500 words, data-forward, analysis-first +- Structure: Hook -> Data -> Analysis -> Professional CTA +- Embed YouTube link at bottom +- Audience: tech relocators, wealth managers, brokers +OUTPUT: 1) LinkedIn post body 300-500w (lead w/ the -7.2% vs +1.7% fragmentation insight, deliver April 17 milestone as context for WHY the divergence, analyze Peninsula micro-markets, close w/ LinkedIn-fit CTA), 2) First comment w/ YT link pin, 3) LinkedIn-native hashtags. More data, less emotion than IG. +""" + +PROMPTS["ad-copy"] = PREAMBLE + """ +DELIVERABLE - Ad Copy Variants (FB/IG + Google paid): +3 variants per platform for A/B. Drive to GHL keyword or landing page. +OUTPUT: +1. FB/IG ADS (3 variants): each w/ Primary Text + Headline + Description + CTA button label. + - V1: Curiosity-gap (lead 1992->2026 reveal) + - V2: Data-forward (-7.2% vs +1.7% fragmentation) + - V3: Problem/solution (Palo Alto prices for nothing) + - Objective: Lead Gen (Instant Form) or Message (GHL pickup) + - Audience: Bay Area 25-54, homeowner/buyer interest, exclude brokers +2. GOOGLE SEARCH ADS (3 combos): target kw east palo alto real estate / epa homes / peninsula agent. 30-char headlines (3/ad), 90-char descriptions (2/ad). +3. CREATIVE DIRECTION: thumbnail/image per variant, video clip recommendation, A/B test plan + budget split. +Fair Housing Special Ad Category MUST be enabled on Meta. +""" + +PROMPTS["full-newsletter"] = PREAMBLE + """ +DELIVERABLE - Full Weekly Newsletter (multi-section "The EPA Report"): + +This is the COMPLETE assembled weekly email, not just a lead section. 7 required sections in this exact order: + +1. HEADER + BRAND BANNER (navy gradient, "The EPA Report" title, issue date) +2. LEAD STORY (200-400 word hook + excerpt + "Watch the full video" YouTube CTA) +3. MARKET UPDATE (4 stat cards: EPA +1.7% YoY, SMC -7.2% YoY, EPA DOM 32 days, rates 6.46%) +4. COMMUNITY & DEVELOPMENT (2-4 bullet updates: milestone, Woodland Park, Flock cameras, digital overhaul) +5. FEATURED CONTENT (blog post teaser card with link) +6. "WHAT'S MY HOME WORTH?" CTA BLOCK (gold button — triggers CMA generator handoff per cma-integration.md) +7. FOOTER (DRE #01466876, contact info, social links, unsubscribe) + +CRITICAL REQUIREMENTS: +- Email-safe HTML: table-based layout, inline styles only, 600px max-width, system fonts (-apple-system, BlinkMacSystemFont, etc.), no JS, no external CSS, no web fonts +- CTA button MUST use href="https://graehamwatts.com/home-value?utm_source=newsletter&utm_campaign=[slug]&utm_medium=email&utm_content=home_value_cta" +- CTA GHL keyword is VALUE (not SELL, not EPA) -- triggers the NEWSLETTER_VALUE_REQUEST tag + Home Value Follow-Up sequence +- Plain text fallback MUST be generated alongside HTML +- Subject line <= 60 chars, preview text <= 100 chars +- Fair Housing compliance (no demographic coded language) +- DRE #01466876 in footer + +OUTPUT: +1. SUBJECT LINE + PREVIEW TEXT +2. FULL EMAIL-SAFE HTML (complete 7-section newsletter) +3. PLAIN TEXT FALLBACK (auto-generated from HTML, 70-char line wrap) +4. METADATA (tracking params, CTA targets, GHL keywords) + +Reference: skills/newsletter-generator/SKILL.md for full specification and cma-integration.md for the home value handoff flow. +""" + +PROMPTS["email"] = PREAMBLE + """ +DELIVERABLE - Weekly Email Newsletter Lead Section: +- Lead story of weekly email ("The EPA Report") +- 350-450 words. Personal tone, written to single reader "Hey [First Name]". +OUTPUT: 1) Subject line (<60 char, curiosity+stat), 2) Preview text (<100 char), 3) Body 350-450w (narrative hook -> milestone + April 17 date -> market data -> BOTH owners AND shoppers w/ different takeaways -> soft video CTA -> primary CTA button), 4) CTA button label + bg color + target URL ("What's My Home Worth?"), 5) Sign-off block (Graeham - REALTOR | Intero | DRE #01466876 | contact links). No salesy language. +""" + +FORMAT_META = { + "yt-long-pt1": ("YouTube Long Pt 1 - Script + SSML", "Length: ~4:30 . 573 words . 16:9 1080p", "Paste into Claude/ChatGPT. Returns: full timestamped script w/ inline shot tags + complete ElevenLabs SSML. Save SSML as .ssml.txt for HeyGen."), + "yt-long-pt2": ("YouTube Long Pt 2 - Production Package", "Editing notes + AI prompts + SEO + 3 alt hooks", "Run AFTER Pt 1. Returns: Jason's editing notes, 3+ Seedance prompts, full YouTube SEO, 3 alt hooks. Split from Pt 1 to avoid truncation."), + "production-brief": ("Production Brief - Crew + Editor", "Consolidated for Peter, John (crew) and Jason (editor)", "One printable doc the crew takes to set. Returns: timing summary, call sheet, shot list, B-roll list, editing notes, AI prompts, export/delivery specs."), + "yt-short": ("YouTube Short - Vertical Cut", "Length: ~30s . 9:16 1080p . cut from long-form", "Front-weighted hook, then data drop, then CTA. Returns: timestamped script w/ shot tags + Shorts description."), + "ig-reel-1": ("Instagram Reel #1 - Hook-Led", "Length: ~30s . 9:16 . burned captions", "Hook-led for algorithmic first-watch. Returns: script, IG caption w/ GHL CTA, hashtags, optional pinned first-comment."), + "ig-reel-2": ("Instagram Reel #2 - Data-Led", "Length: ~20s . 9:16 . stat-card heavy", "Different angle from Reel #1 for data-responsive buyers. Returns: script w/ stat-card specs + caption + hashtags."), + "ig-carousel": ("Instagram Carousel - 8-Slide Arc", "4:5 . optimized for saves + shares", "Reference-content format. Returns: 8-slide content (title+body), design direction per slide, caption w/ GHL CTA."), + "tiktok": ("TikTok - Casual Adaptation", "Length: ~30s . 9:16 . TikTok-native tone", "More conversational than IG. Returns: TikTok script w/ cut markers, caption, location/genre hashtags, audio recommendation."), + "blog": ("Blog - SEO + AEO Optimized", "1000-1200 words . graehamwatts.com/blog", "Full AEO-ready blog w/ FAQ structured data. Returns: title tag, meta, body w/ H2/H3, 3 FAQ, internal links, sources."), + "gmb": ("Google My Business - Local SEO Post", "~250 words . 1 image . CTA button", "Hyper-local SEO. EPA in first sentence. Returns: post body, CTA label+URL, image direction."), + "facebook": ("Facebook - Extended Caption", "200-400 words . cross-post Reel", "Longer caption for FB audience. Returns: post body, first-comment w/ YT link pin, post-type recommendation."), + "linkedin": ("LinkedIn - Professional Post", "300-500 words . data-forward tone", "Professional audience (tech relocators, wealth managers, brokers). Returns: analysis-first body, first comment w/ YT link, LinkedIn-native hashtags."), + "ad-copy": ("Ad Copy - FB/IG + Google Variants", "3 variants per platform . A/B testing", "Paid promotion copy. Returns: 3 FB/IG variants, 3 Google search combos, creative direction, A/B plan. Fair Housing Special Ad Category flagged."), + "email": ("Newsletter - Weekly Email Lead", "350-450 words . What's My Home Worth? CTA", "Lead story of weekly email. Returns: subject, preview, body addressing owners+shoppers, CTA button spec, sign-off."), + "full-newsletter": ("Full Newsletter - Complete Weekly Email", "7 sections . CMA handoff wired . The EPA Report", "The ENTIRE weekly newsletter assembled from 7 sections (header, lead, market update cards, community news, featured content, Home Worth CTA, footer). Gold CTA triggers cma-generator handoff. Paste into Gmail. Also produces plain text fallback."), +} + +# Build panels +panels_html = [] +for key, (label, meta, use_in) in FORMAT_META.items(): + is_active = " active" if key == "yt-long-pt1" else "" + preview = PROMPTS[key][:800].replace('<', '<').replace('>', '>') + panel = f"""
+
+
+
{label}
+
{meta}
+
+
{preview}... + +(Full prompt loaded - click Copy Prompt below to get the complete text.)
+
+ + + + Master prompt: {len(PROMPTS[key]):,} chars +
+
How to use: {use_in}
+
+
""" + panels_html.append(panel) + +# Flow cards (in same order as FORMAT_META) +flow_cards = [] +for i, (key, (label, meta, _)) in enumerate(FORMAT_META.items()): + active_cls = " core active" if key == "yt-long-pt1" else "" + # Short label for card + short_label = label.split(" - ")[0] + short_title = label.split(" - ")[1] if " - " in label else meta.split(".")[0] + # Color tag per format family + if key.startswith("yt-"): + tag = '
' + ("Crew+Edit" if key == "production-brief" else ("~4:30" if key == "yt-long-pt1" else ("Edit+SEO" if key == "yt-long-pt2" else "~30s"))) + '
' + elif key.startswith("ig-"): + tag = '
' + ("~30s" if key == "ig-reel-1" else ("~20s" if key == "ig-reel-2" else "4:5")) + '
' + elif key == "production-brief": + tag = '
Crew+Edit
' + elif key == "tiktok": + tag = '
~30s
' + elif key == "blog": + tag = '
AEO
' + elif key == "gmb": + tag = '
250w
' + elif key == "facebook": + tag = '
200-400w
' + elif key == "linkedin": + tag = '
300-500w
' + elif key == "ad-copy": + tag = '
Paid
' + elif key == "email": + tag = '
400w
' + else: + tag = '
' + meta[:12] + '
' + card = f'
{short_label}
{short_title}
{tag}
' + flow_cards.append(card) + +FLOW_CARDS_HTML = "\n ".join(flow_cards) +PANELS_HTML = "\n".join(panels_html) +PROMPT_LIB_JSON = json.dumps(PROMPTS) + +DASHBOARD = """ + + + + +EPA Two Years Homicide-Free - Production Dashboard | Graeham Watts + + + +
+ +
+
Content Creation Engine · Per-Topic Workflow · Week of April 20, 2026
+

East Palo Alto Just Hit 2 Years Without a Homicide — And It's Changing Peninsula Home Prices

+
A counter-narrative content package built from the April 17, 2026 milestone announcement, cross-referenced against EPA MLS data (+1.7% YoY, DOM cut in half) and Peninsula-wide fragmentation (SMC -7.2% YoY).
+
+
Opportunity Score 10/10 ★
+
Funnel: MOFU → BOFU
+
Pillar 5 + 4
+
GHL Keyword: EPA
+
Target: ~4:30
+
+
Generated April 18, 2026 · Content Creation Engine v2 · Intero Real Estate · DRE #01466876
+
+ +
+ How to use this dashboard: +
    +
  1. Click a format tab below (YouTube Long, Production Brief, IG Reel, Blog, Ad Copy, etc.)
  2. +
  3. Hit the big gold Copy Prompt button — the entire pre-loaded prompt goes to your clipboard
  4. +
  5. Paste into your AI of choice (Claude, ChatGPT, Gemini, or the browser agent)
  6. +
  7. Get the generated content back, review, then hand off for production
  8. +
+ Each prompt already includes Agent Identity + Fair Housing + Date/Year QC + Timing Self-Check + Voice + Topic + AEO stats + Key Facts + GHL CTA. You just copy and paste — no context-setting required. +
+ +
+
Verified Timing Calculation (no generic defaults)
+
~4:30 min
+
+ 573 words of spoken script body × 150 WPM conversational pace × 1.15 pause/B-roll buffer = 4.39 minutes
+ Corrected per SKILL.md mandatory timing calculation rule. This narrative-plus-market-data format has a tight emotional arc; anything past 5 min bloats into filler. +
+
+ +
+ +
Fair Housing Compliance: Passed. Homicide data framed as statistics plus community policy shift, not neighborhood character. No demographic references, no coded language, no school rankings. All property value claims backed by date-stamped stats.
+
+ +

Intelligence Stack — What Backed This Topic

+
+
+
+

📰 Local News

+

EPA officially marked 2 years without a homicide on April 17, 2026. Last homicide: April 2024. 1992 baseline: 42 homicides in 24K population (US leader per capita).

+
Source: Local News Matters, The Almanac — April 17, 2026
+
+
+

📊 MLS Market Data

+

EPA median +1.7% YoY; DOM 66 → 32 days YoY; vs San Mateo County -7.2% YoY. Peninsula operating as fragmented micro-markets.

+
Source: Redfin EPA, Benson Group, Own Team — April 2026
+
+
+

🔍 Search Console

+

"East palo alto real estate" pos 20.5, 10 imp; "epa realtor" pos 17.5, 6 imp. Narrative reset is the unlock.

+
Source: sc-domain:graehamwatts.com — last 7 days
+
+
+

📱 Social Performance

+

Top IG posts avg 10-23 shares. Share-worthy content wins. Counter-narrative + bold stat = share pattern match.

+
Source: Windsor Instagram connector — last 30 days
+
+
+

🏛️ Local Government

+

City of EPA confirmed milestone. Drivers: community partnerships, youth/workforce, modernized policing, integrated neighborhood-department engagement.

+
Source: City of East Palo Alto — April 17, 2026
+
+
+

🎯 BOFU Cross-Reference

+

No overlap with weeks of April 14 or April 21 planned calendar. Fills a content gap.

+
Source: references/topic-history.json
+
+
+
+ +

Opportunity Score Breakdown (10/10)

+
+
3/3
Timeliness
Story broke April 17, 2026
+
3/3
Audience Relevance
Direct property value impact
+
2/2
Content Gap
No existing coverage
+
2/2
Engagement Potential
Counter-narrative + bold stat share pattern
+
+ +
+ 📅 Calendar Integration: This topic is the anchor for its scheduled day. If a higher-priority breaking story arrives, options: (A) Replace this slot with the breaking topic. (B) Add as Sat/Sun interrupt. (C) Re-score on the next ideation pass. → Current weekly calendar +
+ +

Content Derivatives — Click to Get the Prompt

+

Each format opens a pre-built prompt ready to paste into your AI of choice. Full context loaded — just copy and go.

+
+ __FLOW_CARDS__ +
+ +
+__PANELS__ +
+ +

Shot List — Hand to Peter and John

+ + + + + + + + + + + + + + + + +
#Shot DescriptionDurationSetup Notes
1Open Talking Head — Graeham neutral expression (no smile on hook)0:00-0:20Eye-level, 50mm look, clean backdrop
2Archival 1990s news clips / chyrons0:20-0:35Stock archival OR AI-generate
3TH cutback — setup context0:35-1:05Same framing as Shot 1
490s newspaper headlines / period EPA photos1:05-1:15SF Chronicle / Mercury News archive
5TH Act 2 — warmer tone1:15-1:45Small camera repositioning
6Community B-roll — Joel Davis Park, youth programs, modern EPA streets1:45-2:05Shoot locally OR request from City of EPA
7TH milestone reveal — slower pace2:05-2:35Direct-to-camera, closer framing
8EPA City Hall / current streets / community events2:35-2:55Shoot locally
9TH market angle — business tone2:55-3:45TH, stat overlays in post
10Motion graphic stat cards — DOM and price data3:45-4:00Motion graphics (Jason)
11TH CTA — direct, confident4:00-4:30TH, close framing
12End card — Graeham branding4:30Static, 3-4 sec hold
+ +

3 Alternate Hooks (A/B Testing)

+
+
+
PICKED
+

Hook A — Story-led

+

"East Palo Alto was called 'the murder capital of America.' That was 1992. Last week — 34 years later — the city quietly hit a milestone almost nobody outside of here is talking about."

+
+
+

Hook B — Buyer-math-led

+

"If you've been shopping the Peninsula and skipping East Palo Alto — you're paying Palo Alto prices for a problem that stopped existing in 2024. Let me show you the data."

+
+
+

Hook C — Counter-narrative-led

+

"What if I told you the 'murder capital of America' has gone two full years without a single homicide — and the rest of the Peninsula just lost 7% of its home value while East Palo Alto quietly went up?"

+
+
+
Recommendation: Use Hook A as primary. Shares trigger on curiosity + charged phrase + reveal pattern. Hold B and C in reserve; swap if Reel underperforms in first 48 hours.
+ +
+

🚀 Auto-Render Hand-off (HeyGen)

+

Once the Part 1 prompt returns your SSML block, save it to outputs/content-package-2026-04-18-epa-two-years-homicide-free.ssml.txt then run:

+ python3 skills/heygen-elevenlabs-renderer/scripts/full_render.py \\
  --script outputs/content-package-2026-04-18-epa-two-years-homicide-free.ssml.txt \\
  --slug "epa-two-years-homicide-free" \\
  --resolution 1080p \\
  --aspect 16:9
+
-script` and `-production` + sub-keys for video formats; CONTENT_LIBRARY has one key per format. +- Scoring Architecture Panel (Rule 13): both Opportunity Score (25 pts, owned by content-calendar) + and Intent Score (25 pts + freshness ±5, owned by content-creation-engine/references/phases/bofu-intent-scorer.md — absorbed May 2026) rendered side-by-side. + +USAGE — How a build session calls this module: + + from single_topic_dashboard_builder import build_dashboard + + html = build_dashboard( + topic_config={ + "topic_slug": "epa-market-update-april-2026", + "topic_title": "East Palo Alto Market Update — April 2026", + "audience": "sellers", + "market_scope": "East Palo Alto", + "publish_date": "2026-04-30", + "opportunity_score": {...}, # from content-calendar's calendar-{date}.json + "intent_score": {...}, # from outputs/scored-topics-{ts}.json + }, + prompt_library={ + # Non-video formats: single key per format + "blog": "...", + "email": "...", + "gmb": "...", + "facebook": "...", + "ig-carousel": "...", + # Video formats: TWO keys per format — script + production + "yt-long-pt1": "...", # script + SSML regeneration prompt + "yt-long-pt2": "...", # production package regeneration prompt + "yt-short-script": "...", + "yt-short-production": "...", + "ig-reel-1-script": "...", + "ig-reel-1-production": "...", + "ig-reel-2-script": "...", + "ig-reel-2-production": "...", + "tiktok-script": "...", + "tiktok-production": "...", + }, + content_library={ + # One key per format — Blog Track's post-ready content + "blog": "...", + "email": "...", + "gmb": "...", + "facebook": "...", + "ig-carousel": "...", + "yt-long-pt1": "...", + "yt-long-pt2": "...", + "yt-short": "...", + "ig-reel-1": "...", + "ig-reel-2": "...", + "tiktok": "...", + }, + format_meta={ + # Per format: (label, meta description, use_in instructions) + "yt-long-pt1": ("YouTube Long Pt1 - Script + Voice", "8-15 min, 16:9, 1080p", "..."), + ... + }, + ) + + Path("online-content/dashboards/single-topic/{date}-{slug}-production.html").write_text(html, encoding="utf-8") + +The legacy v4 builder loaded these dicts from a sibling sandbox session (paths now dead). v5 +takes them as function arguments — the calling session assembles them from its live data. +""" +import json +import sys +from pathlib import Path + +# Module-level globals — populated by build_dashboard() at call time. Kept as module globals so the +# existing render code (lines 60+ of the original v4 script) can reference them without rewriting. +PROMPTS: dict = {} +CONTENT: dict = {} +FORMAT_META: dict = {} +TOPIC_CONFIG: dict = {} + +def _validate_inputs(prompt_library: dict, content_library: dict, format_meta: dict) -> None: + """Assert key consistency per Rule 2 of single-topic-dashboard-rules.md.""" + video_formats = {"yt-long-pt1", "yt-long-pt2", "yt-short", "ig-reel-1", "ig-reel-2", "tiktok"} + non_video_formats = {"blog", "email", "gmb", "facebook", "ig-carousel"} + + # Every format in format_meta must have a matching CONTENT_LIBRARY entry + for key in format_meta: + if key not in content_library: + raise ValueError(f"format_meta has '{key}' but content_library is missing it") + + # YT Long pt1/pt2: PROMPT_LIBRARY uses raw keys (no -script/-production suffix) because pt1 IS the script side and pt2 IS the production side + for key in ("yt-long-pt1", "yt-long-pt2"): + if key in format_meta and key not in prompt_library: + raise ValueError(f"YT Long format '{key}' is in format_meta but missing from prompt_library") + + # Other video formats need BOTH -script and -production keys in PROMPT_LIBRARY + for key in ("yt-short", "ig-reel-1", "ig-reel-2", "tiktok"): + if key in format_meta: + if f"{key}-script" not in prompt_library: + raise ValueError(f"Video format '{key}' missing '{key}-script' in prompt_library") + if f"{key}-production" not in prompt_library: + raise ValueError(f"Video format '{key}' missing '{key}-production' in prompt_library") + + # Non-video formats need single key in PROMPT_LIBRARY matching format_meta key + for key in non_video_formats: + if key in format_meta and key not in prompt_library: + raise ValueError(f"Non-video format '{key}' missing from prompt_library") + + +def build_dashboard(topic_config: dict, prompt_library: dict, content_library: dict, format_meta: dict) -> str: + """Build a single-topic production dashboard HTML string. See module docstring for input shapes.""" + global PROMPTS, CONTENT, FORMAT_META, TOPIC_CONFIG + _validate_inputs(prompt_library, content_library, format_meta) + PROMPTS = prompt_library + CONTENT = content_library + FORMAT_META = format_meta + TOPIC_CONFIG = topic_config + return _render_html() + + +def _render_html() -> str: + """Render the dashboard HTML using PROMPTS, CONTENT, FORMAT_META, TOPIC_CONFIG module globals. + All the render logic below this line is preserved from the v4 builder, with the panel-render + loop updated for the audience-targeted 3-button pattern (Rule 3 + Rule 4).""" + # The v4 render code follows below. Kept as a single function body for now; could be split + # into smaller helpers (render_hero, render_flow_cards, render_panel, render_scoring_panel, etc.) + # in a future refactor. + print(f"Loaded {len(PROMPTS)} prompts, {len(CONTENT)} content pieces, {len(FORMAT_META)} metas") + +# Format-specific button labels (for Copy Content button — tells user exactly what they're copying) +BUTTON_LABELS = { + "yt-long-pt1": "Copy Script + SSML", + "yt-long-pt2": "Copy Production Package", + "production-brief": "Copy Production Brief", + "yt-short": "Copy Short Script", + "ig-reel-1": "Copy Reel #1", + "ig-reel-2": "Copy Reel #2", + "ig-carousel": "Copy Carousel", + "tiktok": "Copy TikTok", + "blog": "Copy Blog Post", + "gmb": "Copy GMB Post", + "facebook": "Copy Facebook Post", + "linkedin": "Copy LinkedIn Post", + "ad-copy": "Copy Ad Copy", + "email": "Copy Email Lead", + "full-newsletter": "Copy Newsletter HTML", +} + +# Video format → script_key + production_key map (Rule 3 + Rule 4 of dashboard-rules.md). +# For YT Long: pt1 IS the script side, pt2 IS the production side. They cross-reference each other, +# so pt1's "production prompt" button copies pt2's prompt and vice versa. +# For other video formats: single panel with `-script` and `-production` sub-keys in PROMPT_LIBRARY. +VIDEO_FORMAT_KEYS = { + "yt-long-pt1": {"script_prompt_key": "yt-long-pt1", "production_prompt_key": "yt-long-pt2"}, + "yt-long-pt2": {"script_prompt_key": "yt-long-pt1", "production_prompt_key": "yt-long-pt2"}, + "yt-short": {"script_prompt_key": "yt-short-script", "production_prompt_key": "yt-short-production"}, + "ig-reel-1": {"script_prompt_key": "ig-reel-1-script", "production_prompt_key": "ig-reel-1-production"}, + "ig-reel-2": {"script_prompt_key": "ig-reel-2-script", "production_prompt_key": "ig-reel-2-production"}, + "tiktok": {"script_prompt_key": "tiktok-script", "production_prompt_key": "tiktok-production"}, +} + +# Non-video formats — single Copy Prompt button (blog only, regenerates the format's content). +NON_VIDEO_FORMATS = {"blog", "email", "gmb", "facebook", "linkedin", "ad-copy", "ig-carousel", "full-newsletter", "production-brief"} + +# Backwards-compat alias — old code may still reference PAIRINGS. Maps old shape to new for any +# legacy callers, then deprecated. New code should use VIDEO_FORMAT_KEYS directly. +PAIRINGS = {"yt-long-pt1": ("yt-long-pt2", "Copy Production Prompt", "Production package: B-roll, editing notes for Jason, AI video prompts for Seedance, YouTube SEO, 3 alt hooks.")} + +# HeyGen render config — which formats can be rendered as avatar video + recommended avatar per format +HEYGEN_RENDER = { + "yt-long-pt1": {"avatar": "digital_twin", "avatar_id": "159cd7b883724fdb9a51b97dec94df89", "aspect": "16:9", "reason": "Authentic face from real video — best for long-form face-critical content"}, + "yt-short": {"avatar": "fashion_flip", "avatar_id": "b0644e6b20ba414981b7821d88caf675", "aspect": "9:16", "reason": "Higher energy for scroll-stopping shorts"}, + "ig-reel-1": {"avatar": "casual_chic", "avatar_id": "afdc7e3e9f0c45de896fa687c594a216", "aspect": "9:16", "reason": "Approachable everyday energy for hook-led Reel"}, + "ig-reel-2": {"avatar": "freshly_ironed","avatar_id": "09fed5d2c0b74376b6e7313cbb888c86", "aspect": "9:16", "reason": "Polished, data-forward look for stat-heavy Reel"}, + "tiktok": {"avatar": "fashion_flip", "avatar_id": "b0644e6b20ba414981b7821d88caf675", "aspect": "9:16", "reason": "Higher energy matches TikTok's native pacing"}, +} +VOICE_CLONE_ID = "717249201f7745988219b9aeb9041b42" # Graeham Watts Voice Clone (default across all looks) + +# Build panels +panels_html = [] +for key, (label, meta, use_in) in FORMAT_META.items(): + is_active = " active" if key == "yt-long-pt1" else "" + preview = CONTENT[key][:600].replace('<', '<').replace('>', '>') + cchars = len(CONTENT[key]) + is_video = key in VIDEO_FORMAT_KEYS + # pchars is the prompt character count shown in the regenerate UI. For non-video formats, + # PROMPTS[key] exists. For video formats, the script-side prompt is what matters first. + if is_video: + script_prompt_key = VIDEO_FORMAT_KEYS[key]["script_prompt_key"] + production_prompt_key = VIDEO_FORMAT_KEYS[key]["production_prompt_key"] + pchars_script = len(PROMPTS.get(script_prompt_key, "")) + pchars_production = len(PROMPTS.get(production_prompt_key, "")) + pchars = pchars_script # Default for legacy display lines + else: + pchars = len(PROMPTS.get(key, "")) + script_prompt_key = key + production_prompt_key = None + pchars_script = pchars + pchars_production = 0 + + # Peter buttons block — Rule 3 of dashboard-rules.md. + # For VIDEO formats: builds a section with two buttons — Copy Script Prompt (gold outline, + # Peter's script-side regen) + Copy Production Prompt (purple, Peter's production-side regen). + # For NON-VIDEO formats: empty string (the standard regenerate-section below handles them). + peter_buttons_block = "" + if is_video: + peter_buttons_block = ( + '
\n' + '
For Peter — Script Prompt + Production Prompt
\n' + '
What this is: Peter\'s production-side handoff for this format. Copy Script Prompt regenerates the SCRIPT (with SSML/ElevenLabs XML markup, ready for voice gen). Copy Production Prompt regenerates the PRODUCTION PACKAGE (B-roll list, editing notes for Jason, AI video prompts for Seedance, shot list, music direction, thumbnail concept). Two prompts because they\'re typically run as separate AI invocations — output length per response would otherwise truncate.
\n' + '
\n' + ' \n' + ' Script prompt: ' + f"{pchars_script:,}" + ' chars\n' + '
\n' + '
Paste into: Claude/ChatGPT to regenerate the script + SSML/XML for this format.
\n' + '
\n' + ' \n' + ' Production prompt: ' + f"{pchars_production:,}" + ' chars\n' + '
\n' + '
Paste into: Claude/ChatGPT to regenerate the production package (B-roll, editing notes, AI video prompts, shot list, music direction).
\n' + '
\n' + ) + + # HeyGen render instruction block (only for video formats) + render_block = "" + if key in HEYGEN_RENDER: + cfg = HEYGEN_RENDER[key] + fmt_name = label.split(" - ")[-1] if " - " in label else label + render_block = ( + '
\n' + '
🎬 Render This As a Video via HeyGen MCP
\n' + '
\n' + ' What this does: Takes the script above and turns it into a finished avatar video of Graeham — automatically. You don't log into HeyGen, you don't use any CLI, you don't click anywhere in the HeyGen dashboard. One button click here → one paste into Claude → Claude handles the rest via the HeyGen MCP.\n' + '
\n' + '
\n' + '
Step-by-step flow:
\n' + '
    \n' + '
  1. One-time setup: install the HeyGen MCP. Go to docs.heygen.com/docs/heygen-mcp-server. Follow the install (2 min). Paste your HeyGen API key (grab from app.heygen.com/api). After this, Claude has HeyGen as a native tool.
  2. \n' + '
  3. Click the red "Copy Render Instruction" button below. Copies a complete instruction (script pre-filled, avatar choice, voice, aspect, resolution) to your clipboard.
  4. \n' + '
  5. Paste into any Claude session with HeyGen MCP connected (Cowork, Claude Desktop, Claude Code — whichever you use).
  6. \n' + '
  7. Claude asks you to confirm the avatar. Accept the recommendation or swap to a different look.
  8. \n' + '
  9. Claude calls generate_avatar_video directly. Video is queued in HeyGen within ~2 seconds. Claude returns a video_id + HeyGen dashboard URL.
  10. \n' + '
  11. Check status later. Say "check on video [id]" any time — Claude calls get_avatar_video_status. When done, MP4 is downloadable.
  12. \n' + '
\n' + '
\n' + '
\n' + ' Recommended avatar for this format: ' + cfg["avatar"] + '
\n' + ' Why this avatar: ' + cfg["reason"] + '.
\n' + ' Override allowed: when Claude asks, name any of the 6 looks — digital_twin, casual_chic, freshly_ironed, fashion_flip, bespectacled, suburban_serenity.\n' + '
\n' + '
\n' + ' Preview the exact instruction that gets copied\n' + '
Render this video via HeyGen MCP.\n\n' + 'Format: ' + fmt_name + '\n' + 'Avatar: ' + cfg["avatar"] + ' (' + cfg["avatar_id"] + ')\n' + 'Voice: Graeham Watts Voice Clone (' + VOICE_CLONE_ID + ')\n' + 'Aspect: ' + cfg["aspect"] + ' | Resolution: 1080p\n\n' + 'Script to speak:\n' + '[full script from this panel gets pre-filled here when you click Copy]\n\n' + 'Call the HeyGen MCP generate_avatar_video tool. Confirm the avatar choice with me before submitting. Return the video_id and HeyGen dashboard URL so I can check status later.
\n' + '
\n' + '
\n' + ' \n' + ' For MCP users — paste into Claude Desktop w/ HeyGen MCP (auth flow currently broken, so use below)\n' + '
\n' + '
\n' + '
💻 Recommended: One-Line PowerShell Render
\n' + '
One-time setup: save HEYGEN_API_KEY env var on Windows + clone Graehamwatts/skills repo locally. Then this button copies a one-line command that renders this format via HeyGen API. No MCP needed.
\n' + '
python skills/scripts/heygen_render.py --topic __TOPIC_SLUG__ --format ' + key + ' --look ' + cfg["avatar"] + '
\n' + ' \n' + ' Paste into PowerShell, hit Enter, done.\n' + '
\n' + '
\n' + ) + + destination = {'yt-long-pt1': 'YouTube upload page (paste script into description; SSML goes separately into ElevenLabs or HeyGen MCP)', 'yt-long-pt2': 'Production team Slack / Notion doc for Jason the editor', 'production-brief': 'Production call sheet — print for set, share via Notion/Dropbox with Peter, John, Jason', 'yt-short': 'YouTube Shorts upload page', 'ig-reel-1': 'Instagram Reel upload (script + paste caption)', 'ig-reel-2': 'Instagram Reel upload (script + paste caption)', 'ig-carousel': 'Instagram Carousel composer (one slide at a time) + paste caption', 'tiktok': 'TikTok upload page', 'blog': 'Blog CMS (WordPress, Ghost, Webflow, whatever you use)', 'gmb': 'Google My Business post composer', 'facebook': 'Facebook page post composer', 'linkedin': 'LinkedIn post composer', 'ad-copy': 'Meta Ads Manager (FB/IG) + Google Ads campaign builder', 'email': 'Gmail / Mailchimp / Klaviyo compose window', 'full-newsletter': 'Gmail / Mailchimp / Klaviyo — paste the full HTML as the email body'}.get(key, "the destination platform") + + # Regenerate-section (single Copy Prompt button) — used for NON-VIDEO formats only. + # Video formats use peter_buttons_block (defined above) instead. + regenerate_block = "" + if not is_video: + regenerate_block = ( + '
\n' + '
Copy Prompt — use ONLY if you want to regenerate fresh content
\n' + '
What this does: Copies the ORIGINAL PROMPT that would produce this format if you paste it into Claude or ChatGPT. Use this when you want a different angle, tweaked voice, or to run through a different AI. You do NOT need this to post the content above — the gold button already has the finished version. This is a regeneration escape hatch.
\n' + '
\n' + ' \n' + ' Prompt: ' + f"{pchars:,}" + ' chars\n' + '
\n' + '
Only click if: the generated content above doesn't match what you want and you'd like to regenerate with tweaks.
\n' + '
\n' + ) + + panel = ( + '
\n' + '
\n' + '
' + label + '
' + meta + '
\n' + '
\n' + '
Ready to Post
\n' + '
What this is: The finished, production-ready content for this format. Clicking the gold button below copies the complete deliverable to your clipboard — paste directly into the destination platform. No further editing required (though you can always tweak).
\n' + '
' + preview + '\n\n(Full content loaded - click the gold button to grab it all.)
\n' + '
\n' + ' \n' + ' Full content: ' + f"{cchars:,}" + ' chars\n' + '
\n' + '
Paste into: ' + destination + '.
\n' + '
\n' + + render_block + + peter_buttons_block + + regenerate_block + + '
How to use: ' + use_in + '
\n' + '
\n' + '
' + ) + panels_html.append(panel) + +# Flow cards +flow_cards = [] +for key, (label, meta, _) in FORMAT_META.items(): + active_cls = " core active" if key == "yt-long-pt1" else "" + short_label = label.split(" - ")[0] + short_title = label.split(" - ")[1] if " - " in label else meta.split(".")[0] + if key == "production-brief": + tag = '
Crew+Edit
' + elif key.startswith("yt-"): + v = "~4:30" if key == "yt-long-pt1" else ("Edit+SEO" if key == "yt-long-pt2" else "~30s") + tag = '
' + v + '
' + elif key.startswith("ig-"): + v = "~30s" if key == "ig-reel-1" else ("~20s" if key == "ig-reel-2" else "4:5") + tag = '
' + v + '
' + elif key == "tiktok": + tag = '
~30s
' + elif key == "blog": + tag = '
AEO
' + elif key == "gmb": + tag = '
250w
' + elif key == "facebook": + tag = '
200-400w
' + elif key == "linkedin": + tag = '
300-500w
' + elif key == "ad-copy": + tag = '
Paid
' + elif key == "email": + tag = '
Lead 400w
' + elif key == "full-newsletter": + tag = '
Full 7-sec
' + else: + tag = '
Format
' + card = ( + '
' + '
' + short_label + '
' + '
' + short_title + '
' + + tag + '
' + ) + flow_cards.append(card) + +# Build Copy Bank rows (compact format: icon + name + copy button) +cb_rows = [] +cb_family_map = { + "yt-long-pt1": "cb-video", "yt-long-pt2": "cb-prod", "production-brief": "cb-prod", + "yt-short": "cb-video", + "ig-reel-1": "cb-ig", "ig-reel-2": "cb-ig", "ig-carousel": "cb-ig", + "tiktok": "cb-video", + "blog": "cb-blog", + "gmb": "cb-social", "facebook": "cb-social", "linkedin": "cb-social", + "ad-copy": "cb-ads", + "email": "cb-email", "full-newsletter": "cb-email", +} +cb_icon_map = { + "yt-long-pt1": "\U0001F3A5", "yt-long-pt2": "\U0001F3AC", "production-brief": "\U0001F4CB", + "yt-short": "\U0001F4F9", + "ig-reel-1": "\U0001F4F1", "ig-reel-2": "\U0001F4F1", "ig-carousel": "\U0001F5BC\uFE0F", + "tiktok": "\U0001F3B5", + "blog": "\U0001F4DD", + "gmb": "\U0001F4CD", "facebook": "\U0001F4D8", "linkedin": "\U0001F4BC", + "ad-copy": "\U0001F4B0", + "email": "\U0001F4E7", "full-newsletter": "\U0001F4E7", +} +for k, (label, meta, _) in FORMAT_META.items(): + fam = cb_family_map.get(k, "cb-social") + icon = cb_icon_map.get(k, "\U0001F4C4") + btn_label = BUTTON_LABELS.get(k, "Copy") + cb_rows.append( + '
' + '
' + icon + '
' + '
' + '
' + label + '
' + '
' + meta + '
' + '
' + '' + '
' + ) +COPY_BANK = "\n".join(cb_rows) + +FLOW = "\n ".join(flow_cards) +PANELS = "\n".join(panels_html) +PLIB = json.dumps(PROMPTS) +CLIB = json.dumps(CONTENT) + +# HeyGen render config for client-side JS (per-format avatar + aspect + reason) +HEYGEN_JS = {} +for key, cfg in HEYGEN_RENDER.items(): + fmt_label = FORMAT_META[key][0].split(" - ")[-1] if key in FORMAT_META else key + HEYGEN_JS[key] = { + "label": fmt_label, + "avatar": cfg["avatar"], + "avatar_id": cfg["avatar_id"], + "aspect": cfg["aspect"], + "reason": cfg["reason"], + "voice_id": VOICE_CLONE_ID, + } +HRLIB = json.dumps(HEYGEN_JS) + +# ============================================================ +# RESEARCH DATA HTML (static, pre-populated with actual pulled data) +# ============================================================ +RESEARCH_DATA_HTML = """ +
+ +
+ +
+
+

🔍 Google Search Console — Top Queries (Last 7 Days)

+
Source: Windsor MCP / searchconsole / sc-domain:graehamwatts.com. 87 queries total; showing top 25 by impressions. Only "graeham watts" branded query produced clicks (8). The rest are impressions-only — significant traffic opportunity gap.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
QueryClicksImprCTRPos
graeham watts81266.67%1.0
east palo alto ca real estate0100%20.5
are smoke detectors required when selling a house070%13.7
east palo alto ca homes for sale070%24.1
east palo alto real estate070%27.9
palo alto ca houses for sale070%37.6
palo alto ca real estate070%42.9
east palo alto ca houses for sale060%17.2
east palo alto ca new construction for sale060%29.5
east palo alto ca new homes for sale060%24.8
east palo alto realtor060%17.5
find your dream home menlo park ca060%32.3
palo alto ca homes for sale060%42.0
redwood city ca real estate060%49.5
east palo alto homes for sale050%23.2
palo alto real estate agents050%26.4
redwood city ca new homes for sale050%48.4
redwood city homes for sale050%43.4
we buy houses palo alto california050%70.2
houses for sale in east palo alto040%17.8
palo alto real estate agent040%22.5
centennial neighborhood bay area030%1.0
east palo alto ca open houses030%27.3
smoke alarms in houses030%50.0
...smoke detector cluster (15+ queries)035+0%34-76
+
+ +
+

📱 Instagram Performance — Last 30 Days

+
Source: Windsor MCP / Instagram / graeham.watts. 48 rows; top 12 by reach shown. Pattern: Reels dominate, top posts drive 10-23 shares, saves max 4 — content isn't reference-worthy yet.
+ + + + + + + + + + + + + + + + +
DateTypeReachLikesCommentsSavesShares
2026-03-24Post1,503151423
2026-04-10Post1,301210311
2026-03-23Post1,05912022
2026-04-14Post72646000
2026-04-15Post6576113
2026-04-16Post6505112
2026-04-17Post6316202
2026-03-22Post50710301
2026-04-11Post4292000
2026-04-13Post2888001
2026-04-01Post2145010
2026-04-12Post2052002
+
+ +
+

📘 Facebook Performance — Last 30 Days

+
Source: Windsor MCP / Facebook Organic / Graeham Watts Realtor. 19 posts. Impressions only (no reach/engagement data from connector). Average ~20 impressions/post — FB distribution is weak.
+
+
19
Posts (30d)
~1 post / 1.5 days
+
368
Total Impressions
~20 avg/post
+
55
Best Post (Mar 24)
Same day as top IG
+
4
Worst Post (Apr 15)
Algorithmic dip
+
+
+ +
+

🎥 YouTube Performance — Last 30 Days

+
Source: Windsor MCP / YouTube connector. CRITICAL: channel is dying. Revival plan: cross-post Reels as Shorts + commit to 1 Long/week.
+ + + + + +
DateTitleViewsLikesCommentsSubs
2026-03-27House Tour: 2833 Georgetown St, EPA1000
+
+ +
+

📊 MLS Market Data — April 2026

+
Source: Web-sourced via Redfin, Benson Group, Own Team, Palo Alto Online, C.A.R. April 2026 reports. MLS direct login not executed (needs session auth).
+
+
+1.7%
EPA YoY
Median ~$1.1M
+
-7.2%
SMC YoY
Median $1.9M SFH
+
+7.7%
SF YoY
Median $1.5M
+
32 days
EPA DOM
Was 66 a year ago
+
106.9%
SMC Sale-to-List
Bidding wars back
+
6.46%
30yr Mortgage
Freddie Mac weekly
+
$3.5M
Palo Alto Median
Exclusive $5M+
+
+27%
Luxury Sales YoY
Peninsula-wide
+
+
+ +
+

📰 Local News & Events (last 7 days)

+
Source: Claude web search — queries "East Palo Alto news April 2026", "EPA development", "SMC real estate news", "Bay Area housing market".
+ +
+ +
+

⚡ Trigger Events — Bay Area Tech Layoffs (WARN filings)

+
Source: WARN Firehose, SF Bay Area Times, ABC7. Feeds into buyer/seller trigger-event content.
+
    +
  • APR 28Amazon: 769 Bay Area employees (WARN filed Feb 3, 2026) — effective date 10 days out
  • +
  • MAR 20Meta Platforms: 50 jobs in Menlo Park + 52 in Sunnyvale — Reality Labs division
  • +
  • YTD95,278 tech employees impacted YTD 2026 (882/day national pace)
  • +
  • HISTMenlo Park 2009-2026: 142 WARN notices, 10,138 workers; Meta 30% of total
  • +
+
+ +
+

📅 Topic History — Previously Covered / Planned

+
Source: references/topic-history.json. Rolling 4-week window. Used to filter for content-gap score + prevent duplication.
+ + + + + + + + + + +
Week OfTitleFunnelPillarMarketGHL
Apr 143 Pricing Mistakes EPA Sellers Make in 2026BOFUBuyer/Seller EdEPASELL
Apr 21 (planned)A Tale of Two Markets: AI Boom vs LayoffsMOFUMarket DataPeninsulaMARKET
Apr 21 (planned)Amazon Just Laid You Off — Home Equity MovesBOFUTrigger EventsBay AreaOPTIONS
Apr 21 (planned)The Insurance Crisis Nobody Warned Bay Area BuyersBOFUBuyer/Seller EdBay AreaREADY
Apr 21 (planned)EPA Affordable Housing Policy ShiftMOFUCommunityEPAEPA
Apr 21 (planned)Best Tacos from EPA to Redwood CityTOFULifestyleEPA-RWC
+
+ +
+

🤖 Data Pull Metadata

+
Transparency for future rerun / debugging.
+
+
Apr 18 2026
Pull Timestamp
Per-topic research (Phase R)
+
8/8
Sources Hit
All per-topic research sources
+
7 days
Search Console
Apr 11-17
+
30 days
Social Perf
Mar 19-Apr 17
+
Windsor MCP
Data Layer
7 connectors live
+
MLS manual
Gap
Direct login not run
+
+
+
+""" + +# ============================================================ +# HEAD + MAIN BODY +# ============================================================ +HEAD = """ + + + + +EPA Two Years Homicide-Free - Production Dashboard v4 | Graeham Watts + + + +
+
+
+ + Single-Topic Dashboard +
+ + 📅 View Weekly Calendar — Week of April 27 + + +
+
+
+ +
+
Content Creation Engine · Phase G Per-Topic Dashboard · v5 Scoring Architecture Panel · Week of April 20, 2026
+

East Palo Alto Just Hit 2 Years Without a Homicide — And It's Changing Peninsula Home Prices

+
A counter-narrative content package built from the April 17, 2026 milestone announcement, cross-referenced against EPA MLS data (+1.7% YoY, DOM cut in half) and Peninsula-wide fragmentation (SMC -7.2% YoY).
+
+
Opportunity 23/25 · Intent 20/25 ★
+
Funnel: MOFU → BOFU
+
Pillar 5 + 4
+
GHL Keyword: EPA
+
Target: ~4:30
+
+
Generated April 18, 2026 · Content Creation Engine v4 · Intero Real Estate · DRE #01466876
+
+ +__RESEARCH_DATA_TOP__ + +
+ How to use this dashboard: +
    +
  1. Click any of the 15 format buttons below (2 rows — Newsletter buttons are at the end of row 2).
  2. +
  3. Hit the gold "Copy [Format]" button (e.g., "Copy Script + SSML", "Copy Newsletter HTML") — grabs the production-ready deliverable, paste into YouTube/IG/Gmail/etc.
  4. +
  5. The purple "Copy Production Content" button (only on YT Long Pt 1) also grabs the B-roll + editing package for Jason.
  6. +
  7. The gold outline "Copy Prompt" button regenerates a fresh version through Claude/ChatGPT.
  8. +
  9. Click "Show Full Research Data" at the very top of this page to expand all raw data that backed this topic (Search Console, social perf, MLS, news, topic history).
  10. +
+
+ +
+
Verified Timing Calculation (no generic defaults)
+
~4:30 min
+
+ 573 words of spoken script body × 150 WPM × 1.15 pause/B-roll buffer = 4.39 minutes
+ Corrected per SKILL.md mandatory timing calculation rule. +
+
+ +
+ +
Fair Housing Compliance: Passed. Homicide data framed as statistics plus community policy shift, not neighborhood character. No demographic references, no coded language, no school rankings.
+
+ +

🧠 Intelligence Stack — Where This Topic's Data Came From

+
+
+
+

📱 Instagram ACTIVE — 100%

+

Account: @graeham.watts · ID 17841411632681720
Source: Windsor.ai MCP → IG Graph API

+
Fields pulled: date, reach, likes, comments, shares, saves (48 rows, last 30 days). Known gap: IG Graph API returns NULL for impressions when media_type is NULL for some posts — reach is the reliable metric.
+
+
+

🎥 YouTube LIMITED — Data gap

+

Account: graehamwatts@gmail.com · Windsor ID 6631

+
What we got: 1 video in last 30 days, 1 view total. What this means: Your YouTube channel is effectively dormant. This topic's YT Long asset is the first real content in weeks — cross-post the YT Short to revive the channel.
+
+
+

📘 Facebook ACTIVE

+

Page: Graeham Watts Realtor · ID 375568976359198

+
Fields pulled: post_impressions only (reach, engagement, reactions not available via connector). 19 posts last 30 days, avg 20 impressions. FB distribution is weak — treat as cross-post, not primary.
+
+
+

🔍 Google Search Console ACTIVE — 100%

+

Property: sc-domain:graehamwatts.com

+
Fields pulled: query, clicks, impressions, ctr, position. 87 unique query rows last 7 days. Strongest dataset — drives the SEO / AEO / blog angle for this topic.
+
+
+

🏛️ GoHighLevel CRM ACTIVE

+

Location: Intero Real Estate · ID 6wuU3haUH7uNeT20E3UZ

+
Use this topic: Validated the GHL keyword for this topic's CTA is active in a workflow (comment-keyword trigger + follow-up sequence). Pre-flight check before shipping.
+
+
+

📍 Google My Business ACTIVE

+

Location: Graeham Watts - Realtor

+
Use this topic: GMB derivative on the dashboard is pre-formatted for local SEO. Review/search metrics pulled separately by the weekly social report.
+
+
+

📰 Local News (Web Search) ACTIVE

+

Source: Claude live web search

+
Use this topic: Sourced the core news event(s) + market data from primary sources. Cross-referenced against at least 3 independent outlets before including a stat.
+
+
+

🤖 Apify — Reddit STORED

+

Datasets: 3 prior-campaign scrapes (r/bayarea, r/realestate, r/homeowners)

+
Use this topic: Topic-demand validation — confirmed real audience questions exist before scoring. No fresh scrape this week.
+
+
+
+ +

📊 Recent Performance — What's Actually Moving

+

What this shows: Your actual performance numbers for the last 2 weeks (real, not projected). Use this as the reality check for any topic decision — if Instagram reach spiked last week, the content pattern that drove it should inform what we ship next.

+ + + + + + + + + + +
MetricLast Week
(Apr 13-19)
Prior Week
(Apr 6-12)
WoW Change4-Week Avg
Instagram Reach3,4842,290▲ 52%2,125/wk
Instagram Engagement (likes+comments+shares+saves)7859▲ 32%55/wk
Facebook Impressions96155▼ 38%134/wk
GSC Impressions140205▼ 32%198/wk
GSC Clicks83▲ 167%7/wk
YouTube Views010/wk
+
What this tells us: IG reach is accelerating (Apr 17 alone drove 631 reach on 1 Reel). FB is weak and not worth optimizing. GSC clicks jumped from branded query improvements. YouTube is dying — cross-posting this topic's Reels as Shorts is the cheapest revival play.
+ +

🎯 Content Type Performance — What's Working Right Now

+

What this shows: The posts you actually shipped in the last 30 days, grouped by topic type, with avg reach per post. Use this to pick the hook style for this topic.

+
+
+

📈 Data / Market (Prices, rates, comps, stats) — TOP

+
Posts shipped (30d)5
+
Avg reach/post1,720
+
Top post reach1,503
+
Performance tier#1 Winner
+
+
+

🌍 Discovery (Area tours, neighborhood walks)

+
Posts shipped (30d)4
+
Avg reach/post1,450
+
Top post reach1,301
+
Performance tierGood
+
+
+

🏠 Listing / Promo (Property showcases, open house)

+
Posts shipped (30d)3
+
Avg reach/post892
+
Top post reach1,059
+
Performance tierBelow average
+
+
+
Why this topic matches: Data/Market content is your #1 performer (93% higher avg reach than Listing/Promo). This topic is a data-forward piece — ships into your winning content lane.
+ +

🔍 Google Search Console — Top Demand (What People Are Actually Searching)

+

What this shows: Real search queries from the last 7 days that brought people to your site. Position = where you rank. Impressions = how many times you appeared. Branded query ("graeham watts") gets clicks; most others are impressions-only — meaning Google ranks you but the headlines aren't compelling enough yet.

+
+
+

Top Query (Branded)

+
graeham watts
+
8 clicks · 12 imp · pos 1.0 · 66.67% CTR
+
+
+

EPA Real Estate Cluster

+
east palo alto ca real estate — 10 imp, pos 20.5
+
east palo alto realtor — 6 imp, pos 17.5
+
east palo alto homes — 5 imp, pos 23.2
+
+
+

Smoke Detector Cluster (SEO Gap)

+
15+ queries about CA smoke detector requirements
+
Ranking positions 13-76, 35+ impressions, zero clicks. Pure content-gap opportunity.
+
+
+

Palo Alto Market Cluster

+
palo alto real estate — 7 imp, pos 42.9
+
how is home value calculated in palo alto — 2 imp, pos 21.5
+
we buy houses palo alto — 5 imp, pos 70.2
+
+
+
What this means for this topic: Peninsula buyers are actively searching for property + market info right now. This topic is engineered to rank for the cluster where you already have impressions — turning position 20-30 into page 1.
+ +
+
+ +

Scoring Architecture — Why This Topic Ships

+

Two scores, two questions. Opportunity Score (owned by content-calendar) answers "should we cover this topic THIS WEEK vs other candidates?" Intent Score (owned by bofu-scorer, Phase 3 of the content-creation-engine) answers "what's the BOFU intent of this topic for CTA and funnel decisions?" Both live here expanded — per Rule 13, no toggle.

+
+ +
+
Table A — Opportunity Score 23/25
+
Owner: content-calendar · Source: outputs/calendar-data/calendar-2026-04-27.json
+ + + + + + + + + + +
CriterionScoreSource / Notes
Performance Signal4/5IG reach +52% WoW, data/market content is your #1 lane (1,720 avg reach)
Search Demand5/5GSC: 15+ Peninsula home-value queries, pos 13-42, zero clicks = content gap
Audience Intent4/5Reddit + Nextdoor + news comments all confirming demand
Competitive Gap5/5Zero competitor coverage of the homicide-free milestone + home value angle
Timeliness5/5Story broke April 17, 2026 — 48hr news window still open
Total23/25Threshold: must_create (22-25 range)
+
+ +
+
Table B — Intent Score 20/25
+
Owner: content-creation-engine/references/phases/bofu-intent-scorer.md · Source: outputs/scored-topics-{ts}.json
+ + + + + + + + + + + + +
CriterionScoreSource / Notes
Inquiry Type Match4/5Process inquiry (home value impact) with Property overlap
Intent Matrix Position3/5CONSIDERATION (MOFU), Voluntary + Independent → BOFU via COSTS keyword CTA
Source Confirmation5/53+ platforms: Google PAA, Reddit r/bayarea, Nextdoor EPA, local news comments
Emotional Temperature4/5Moderate-to-high — buyers skipped EPA based on old data, now second-guessing
Local Relevance5/5Hyperlocal — EPA specific, with Peninsula comparative frame
Base Total21/25Before freshness adjustment
Freshness Adjustment−1EPA used 2x in last 2 weeks (market overlap, different angle) — small penalty
Final Total20/25Threshold: ships (≥18)
+
+ +
+ +
+ 📅 Calendar Integration: This topic is the anchor for its scheduled day. If a higher-priority breaking story comes in, three options: (A) Replace this slot with the breaking topic and bump this to next week. (B) Add the breaking topic as a Sat/Sun interrupt. (C) Re-score on the next ideation pass. → Current weekly calendar +
+ +

📅 7-Day Posting Calendar — When to Ship Each Format

+

What this shows: Recommended publishing schedule for this topic across 7 days. Times are based on your actual IG performance data (top posts were 6-9am and 5-8pm). Each day card links to the matching format panel above so you can jump straight to copying.

+
+
Mon
Day 1
9:00 AM🎥 YouTube Long publishes
6:00 PM📱 IG Reel #1 + FB cross-post
+
Tue
Day 2
8:00 AM📹 YouTube Short
7:00 PM🎵 TikTok
+
Wed
Day 3
7:00 AM📝 Blog post publishes
10:00 AM📍 GMB post
+
Thu
Day 4
8:00 AM💼 LinkedIn post
6:00 PM📱 IG Reel #2 (data-led)
+
Fri
Day 5
9:00 AM📧 Newsletter send
2:00 PM📘 Facebook extended post
+
Sat
Day 6
10:00 AM🖼️ IG Carousel (saves best on weekends)
💰 Ad campaigns continue
+
Sun
Day 7
📊 Review Week 1 analytics
🔬 Plan Week 2 derivatives
+
+
Why this order: YouTube Long first (longest shelf life, primes retargeting). Short-form (Reel #1, YT Short, TikTok) Mon-Tue hits peak algorithmic distribution. Blog Wednesday for SEO indexing before Friday newsletter references it. Newsletter Friday because your subscribers open email at end-of-workweek. Carousel Saturday because IG carousel saves peak on weekends.
+ +

Content Derivatives — 15 Formats Ready

+

Each format has a Copy button (gold, format-specific label like "Copy Script" or "Copy Newsletter HTML") + Copy Prompt (gold outline, for regeneration). YT Long Pt 1 also has a paired Copy Production Content (purple) button. Scroll down — 2 newsletter buttons are in row 2.

+
+ __FLOW__ +
+ +
+__PANELS__ +
+ +

Shot List — Hand to Peter and John

+ + + + + + + + + + + + + + + + +
#Shot DescriptionDurationSetup Notes
1Open Talking Head — Graeham neutral expression (no smile on hook)0:00-0:20Eye-level, 50mm look, clean backdrop
2Archival 1990s news clips / chyrons0:20-0:35Stock archival OR AI-generate
3TH cutback — setup context0:35-1:05Same framing as Shot 1
490s newspaper headlines / period EPA photos1:05-1:15SF Chronicle / Mercury News archive
5TH Act 2 — warmer tone1:15-1:45Small camera repositioning
6Community B-roll — Joel Davis Park, youth programs1:45-2:05Shoot locally OR request from City of EPA
7TH milestone reveal — slower pace2:05-2:35Direct-to-camera, closer framing
8EPA City Hall / current streets / events2:35-2:55Shoot locally
9TH market angle — business tone2:55-3:45TH, stat overlays in post
10Motion graphic stat cards — DOM and price data3:45-4:00Motion graphics (Jason)
11TH CTA — direct, confident4:00-4:30TH, close framing
12End card — Graeham branding4:30Static, 3-4 sec hold
+ +

📋 Copy Bank — All 15 Formats in One Place

+

What this is: Every format's production-ready content as a quick-copy button, stacked in one section. Use this when you want to batch-copy multiple formats without clicking through the tabs above. Color-coded by format family (video red, Instagram pink, blog green, social blue, email gold).

+
+__COPY_BANK__ +
+
How this differs from the tabs above: Tabs show the full preview + render instructions + prompt. Copy Bank is just the Copy Content buttons stacked for speed. Use Copy Bank for batch-shipping, tabs for deep-diving a single format.
+ +

3 Alternate Hooks (A/B Testing)

+
+
PICKED

Hook A — Story-led

"East Palo Alto was called 'the murder capital of America.' That was 1992. Last week — 34 years later — the city quietly hit a milestone almost nobody outside of here is talking about."

+

Hook B — Buyer-math-led

"If you've been shopping the Peninsula and skipping East Palo Alto — you're paying Palo Alto prices for a problem that stopped existing in 2024. Let me show you the data."

+

Hook C — Counter-narrative-led

"What if I told you the 'murder capital of America' has gone two full years without a single homicide — and the rest of the Peninsula just lost 7% of its home value while East Palo Alto quietly went up?"

+
+
Recommendation: Hook A as primary. Shares trigger on curiosity + charged phrase + reveal pattern.
+ +
+

🚀 Power-User Alternative: ElevenLabs + HeyGen Pipeline (Optional)

+

TLDR: You probably don't need this. The red Render buttons per format (above) are the recommended path — they use the HeyGen MCP and handle everything automatically. This section is the OLD manual pipeline that uses ElevenLabs for voice + HeyGen for avatar, for when you want more granular voice control (custom SSML tags, specific pacing).

+

What this pipeline does (if you choose to use it):

+
    +
  1. Takes the SSML block from YouTube Long Pt 1's "Ready to Post" content.
  2. +
  3. Synthesizes Graeham's cloned voice via ElevenLabs (better prosody control than HeyGen's default TTS).
  4. +
  5. Uploads the resulting MP3 to HeyGen.
  6. +
  7. Renders the avatar video in HeyGen using that MP3 as the audio track.
  8. +
  9. Downloads the finished MP4 to your outputs folder.
  10. +
+

To use: Click Copy Script + SSML on YouTube Long Pt 1, paste just the <speak>...</speak> block into a new file at outputs/content-package-2026-04-18-epa-two-years-homicide-free.ssml.txt, then run this command in your terminal:

+ python3 skills/heygen-elevenlabs-renderer/scripts/full_render.py \\\\
  --script outputs/content-package-2026-04-18-epa-two-years-homicide-free.ssml.txt \\\\
  --slug "epa-two-years-homicide-free" \\\\
  --resolution 1080p \\\\
  --aspect 16:9
+
+
Voice: Graeham clone Pa3vOYQHHpLJn1Tf7hnP
+
Avatar: 9a3600b16f604059b6ab8b9a55e29ea9
+
GHL Keyword: EPA
+
+
+ + + +
+ + + + +""" + +# Substitute placeholders +DASHBOARD = HEAD +DASHBOARD = DASHBOARD.replace("__RESEARCH_DATA_TOP__", RESEARCH_DATA_HTML) +DASHBOARD = DASHBOARD.replace("__FLOW__", FLOW) +DASHBOARD = DASHBOARD.replace("__PANELS__", PANELS) +DASHBOARD = DASHBOARD.replace("__PLIB__", PLIB) +DASHBOARD = DASHBOARD.replace("__CLIB__", CLIB) +DASHBOARD = DASHBOARD.replace("__HRLIB__", HRLIB) +DASHBOARD = DASHBOARD.replace("__COPY_BANK__", COPY_BANK) +DASHBOARD = DASHBOARD.replace("__TOPIC_SLUG__", "epa-two-years-homicide-free") + +OUT = Path("/var/tmp/stage3/skills/online-content/dashboards/single-topic/2026-04-18-epa-two-years-homicide-free-production.html") +OUT.write_text(DASHBOARD, encoding="utf-8") + +print(f"WROTE: {OUT}") +print(f"size={len(DASHBOARD):,} prompts={len(PROMPTS)} content={len(CONTENT)} panels={len(panels_html)} cards={len(flow_cards)}") + +# ============================================================================ +# Auto-unify (added 2026-04-21) +# Runs the canonical UNIFIED_FINAL_V2 post-processor on the file we just wrote +# so this newly-generated dashboard inherits: +# - Consolidated stylesheet (hero h1 contrast, card unification, etc) +# - v5 hero with clickable badge tooltips + plain-English timing +# - "Why This Topic?" research accordion (collapsed) +# - Calendar clarifier + inline help blocks +# - Crew-tool accordions (Shot List / Hooks / ElevenLabs collapsed) +# This replaces the old manual post-processing step. +# Auto-unify removed 2026-04-29 — unify_final.py was a one-time migration script. diff --git a/skills/content-creation-engine/templates/video-research/notes-mode-a.md b/skills/content-creation-engine/templates/video-research/notes-mode-a.md new file mode 100755 index 0000000..47a68f0 --- /dev/null +++ b/skills/content-creation-engine/templates/video-research/notes-mode-a.md @@ -0,0 +1,20 @@ +--- +source: {source_url} +title: {title} +channel: {channel} +length: {duration} +captured: {captured_iso} +mode: transcript-only +slug: {slug} +--- + +# {title} + +**Channel:** [{channel}]({channel_url}) +**Source:** [{source_url}]({source_url}) +**Length:** {duration} | **Captured:** {captured_human} +**Transcript method:** {transcript_method} + +## Transcript + +{transcript_with_timestamps} diff --git a/skills/content-creation-engine/templates/video-research/notes-mode-b.md b/skills/content-creation-engine/templates/video-research/notes-mode-b.md new file mode 100755 index 0000000..337d9c3 --- /dev/null +++ b/skills/content-creation-engine/templates/video-research/notes-mode-b.md @@ -0,0 +1,91 @@ +--- +source: {source_url} +title: {title} +channel: {channel} +length: {duration} +captured: {captured_iso} +mode: frame-by-frame +focus_range: {focus_range} +frame_count: {frame_count} +topic: {topic} +slug: {slug} +--- + +# {title} — Visual Analysis + +**Channel:** [{channel}]({channel_url}) +**Source:** [{source_url}]({source_url}) +**Length:** {duration} | **Captured:** {captured_human} + +## TLDR + +{tldr_3_4_sentences} + +## Hooks (First 0:00–0:10) + +**Visual hook (0:00–0:03):** +![Opening frame](frames/{hook_frame}) +{visual_hook_description} + +**Audio hook (first spoken line):** +> "{audio_hook_quote}" + +**Why it works:** {hook_analysis} + +## Key Concepts + +{key_concepts_list} + +## Scene-by-Scene Notes + +{scene_by_scene_blocks} + +## B-Roll Catalog (for reverse-engineering) + +| Shot Type | Timestamps | % of runtime | Notes | +|---|---|---|---| +{broll_table_rows} + +**Cut pacing:** {cuts_per_minute_avg} cuts/min average. Energy arc: {energy_arc_description} + +## On-Screen Text Catalog + +| Timestamp | Text | Style | +|---|---|---| +{onscreen_text_rows} + +## Production Style Fingerprint + +- **Color grade:** {color_grade} +- **Typography:** {typography} +- **Motion graphics style:** {motion_graphics_style} +- **Aspect ratio / framing:** {aspect_framing} +- **Brand signals:** {brand_signals} + +## Code & Commands (if any) + +{code_blocks} + +## Replicate-This Brief (for HeyGen + Higgsfield) + +If you wanted to recreate this video's structure with your own content: + +**Hook (0:00–0:03):** +- Visual: {replicate_hook_visual} +- Audio: {replicate_hook_audio} + +**Body (0:03–end):** +{replicate_body_structure} + +**CTA placement:** {replicate_cta} + +## Open Questions / Unknowns + +{open_questions} + +## Source + +- Original video: {source_url} +- Captured: {captured_iso} +- Cache key: {slug} +- Frame count: {frame_count} ({scene_change_count} scene changes + {coverage_floor_count} coverage-floor frames) diff --git a/skills/context-engineer/SKILL.md b/skills/context-engineer/SKILL.md new file mode 100755 index 0000000..4b5cafe --- /dev/null +++ b/skills/context-engineer/SKILL.md @@ -0,0 +1,217 @@ +--- +name: context-engineer +description: Context-window diagnostic and optimization engine for Claude sessions and skills. Explains what's eating the context budget (system prompt, history, loaded files, skill bodies, tool results), estimates token usage by category, and helps decide what to keep, move to references, summarize, or drop. Audits SKILL.md files — flags anything over ~500 lines, recommends tiered structure (frontmatter vs body vs references/ vs assets/). Use ANY time the user mentions context window, context length, token budget, token limit, running out of context, context engineering, optimize my prompt, optimize my skill, my skill is too long, my prompt is too long, context management, context bloat, what's in my context, trim my prompt, tiered context, progressive disclosure, skill audit, or skill review. Over-trigger — if the user mentions prompt length, context, or asks "why is this slow", use this skill. +--- + +# Context Engineer — Diagnose & Optimize the Context Window + +You are a context-window architect. Your job: take a messy, bloated, or expensive context and turn it into a lean, tiered system where each piece of information lives at the right level of disclosure. + +The premise: Claude's context window is a budget. Every token in that budget either earns its keep by improving the answer, or it steals attention from tokens that would. Context engineering is the discipline of spending that budget well. + +--- + +## Part 1 — Diagnosing an existing context + +When the user says "my context is full" or "my prompt is too long", don't guess. Run the diagnostic. + +### Step 1: Categorize what's in the window + +Every token in a Claude session falls into one of these categories: + +1. **System prompt** — instructions, app-level context, tool descriptions. Usually fixed for a given surface (Claude.ai, Claude Code, Cowork, etc.) but can include user-preferences blocks. +2. **Conversation history** — prior user messages + Claude's prior responses. Grows every turn. +3. **Loaded files** — file contents read via Read or attached. These persist in the window until the conversation ends. +4. **Skill bodies** — any SKILL.md that's been loaded, plus any references/ the skill has pulled in. +5. **Tool results** — search results, bash output, subagent reports, MCP responses. Can be huge (especially web fetch / directory listings). +6. **Your own last response** — assistant messages count too. + +When asked "what's in my context?", produce a table like this: + +| Category | Est. tokens | % of window | Notes | +|---|---|---|---| +| System prompt | 15,000 | 7% | Fixed, includes user prefs block | +| Conversation history | 48,000 | 24% | Last 12 turns, heavy on file reads | +| Loaded files | 62,000 | 31% | 3 PDFs, 1 large CSV | +| Skill bodies | 8,000 | 4% | 2 skills loaded (pdf, xlsx) | +| Tool results | 55,000 | 28% | One bash call returned a 40k char log | +| Last response | 12,000 | 6% | Report with inline tables | +| **Total** | **200,000** | **100%** | | + +You usually won't have exact counts. Estimate: +- **English prose:** ~0.75 tokens per word, or ~4 chars per token +- **Code:** ~0.5 tokens per character of source (slightly more dense than prose) +- **JSON / structured data:** ~3 chars per token (punctuation overhead) + +See `references/token_estimation.md` for more precise heuristics and how to compute this from files on disk. + +### Step 2: Identify the offenders + +The categories that are over-indexed compared to their value. Common patterns: + +- **Runaway tool results** — a single bash call, grep, or web fetch that dumped more than the user needed. Usually the biggest win. +- **Re-read files** — the same file Read multiple times across turns because the model forgot it was already in context. +- **Verbose skill bodies** — a SKILL.md over 500 lines that could be split into SKILL.md + references. +- **Pasted data the user could have linked** — the user pasted a 20,000-word document when a file attachment would have done the same job more efficiently. +- **Repetitive instructions** — user restates rules in every turn ("remember, use TypeScript"). Better to put this in a persistent system instruction or a CLAUDE.md. +- **Accumulated errors** — failed tool calls that returned multi-KB error traces. + +### Step 3: Triage + +For each offender, make one of these calls: + +| Decision | When to use | +|---|---| +| **Keep** | Actively referenced in the current task. Needed in active context. | +| **Move to reference file** | Used occasionally. Load on demand via Read. | +| **Summarize** | Important to remember, but the details don't matter. Replace with a 2–3 sentence summary. | +| **Drop** | No longer relevant. Stale search results, old tool output, abandoned branches. | + +Write up the triage like a punch list: + +``` +Triage — Current context (est. 200k / 200k) +- Tool result from `bash: find /` — DROP (obsolete, no longer needed) +- 40k-char server log — SUMMARIZE to "server returned 500s from 14:32 to 14:47, root cause = DB pool exhausted" +- pdf/SKILL.md fully loaded — MOVE TO REFERENCE (already know the workflow, reload if needed) +- Original user spec doc — KEEP (actively cited in this session) +``` + +--- + +## Part 2 — Auditing a SKILL.md for context efficiency + +When the user says "audit my skill" or "why is this skill eating so much context", run the skill audit. + +### The tiered context model + +A well-designed skill has four tiers, each loaded at a different trigger: + +| Tier | What | When loaded | Budget | +|---|---|---|---| +| 1. Frontmatter | `name`, `description` | Always, even when skill doesn't trigger | ~200 words | +| 2. SKILL.md body | The core workflow + decision logic | When the skill triggers | <500 lines ideal | +| 3. `references/` | Detailed docs, rubrics, playbooks | On demand, when SKILL.md body tells Claude to Read them | Unbounded | +| 4. `assets/` and `scripts/` | Templates, fonts, images, executables | On demand; scripts can run without loading source | Unbounded | + +The key insight: **progressive disclosure**. Information that's used in 10% of invocations should not sit in the 90% of context windows where the skill is loaded. + +### Red flags in a SKILL.md + +When reviewing a SKILL.md, flag: + +- **Body over 500 lines.** Anything over 500 lines pays for itself in every invocation. Hard question: does every line here actually help on a typical run, or is 80% of it edge-case handling that should live in references? +- **Multiple long embedded examples.** Examples are valuable but expensive. Put 1–2 sharp examples in SKILL.md; move the rest to `references/examples.md`. +- **Rules restated multiple times.** If you wrote "ALWAYS use X" in three places, pick one and link to it. +- **Long code blocks.** A 60-line Python function inline in SKILL.md is almost always better placed in `scripts/`. +- **Framework / domain-specific detail for N frameworks.** If the skill handles AWS + GCP + Azure, don't load all three docs; load the one that matches the user's request. Put each in its own reference file. +- **No table of contents on references over 300 lines.** Large reference files should have a TOC up top so Claude (or a reader) can jump to the right section without scanning the whole thing. + +### The skill audit output format + +``` +# Skill Audit — [skill-name] + +## Summary +- SKILL.md: XXX lines (target: <500) +- references/: [list of files, sizes] +- Overall context efficiency: Good / Needs work / Bloated + +## What to move to references/ +| Lines in SKILL.md | What it covers | Move to | +|---|---|---| +| 120-260 | The full Flesch formula walkthrough + examples | references/readability.md | +| 340-420 | Domain-specific playbook for B2B SaaS | references/b2b_saas.md | + +## What to cut entirely +- Lines XX-YY: repeated instruction already covered at line ZZ +- Lines AA-BB: example that doesn't add new information beyond the first example + +## What to keep as-is +- Core workflow steps +- Decision tree for framework selection +- One or two canonical examples + +## Suggested tier structure after the refactor +[show the proposed directory layout] + +## Estimated token savings +Before: XXXX tokens on every invocation +After: YYYY tokens on typical invocation (references loaded on demand) +Savings: ZZ% on the 70% of runs that don't need the deep reference +``` + +--- + +## Part 3 — Designing context from scratch + +When the user is building a new skill or system prompt, design the context tier-first. + +### The three questions + +Before writing a single line, answer: + +1. **What runs on every invocation?** → SKILL.md body. Lean, imperative, includes the decision logic. +2. **What runs only for specific cases?** → references/ files, named by case. Loaded on demand. +3. **What doesn't need to live in context at all?** → scripts/ (executed) or assets/ (templated). + +### Progressive disclosure in practice + +Example refactor — a hypothetical `video-creator` skill that handles three formats (MP4 slideshow, React-based Remotion, HeyGen avatar): + +**Bad (monolithic):** +``` +video-creator/ +└── SKILL.md (1,200 lines — all three formats inline) +``` + +**Good (tiered):** +``` +video-creator/ +├── SKILL.md (250 lines — format selection + shared workflow) +└── references/ + ├── mp4_slideshow.md (400 lines, loaded only for slideshow jobs) + ├── remotion.md (600 lines, loaded only for Remotion jobs) + └── heygen.md (350 lines, loaded only for HeyGen jobs) +``` + +Same total information; 70% of invocations pay one-fourth the token cost. + +See `references/patterns.md` for more tiered-context patterns (domain-organized, variant-organized, and phase-organized). + +--- + +## Part 4 — Common anti-patterns + +Load this via `references/anti_patterns.md` when diagnosing a specific mess. Quick summary here: + +1. **The kitchen sink** — one giant SKILL.md that handles every case. Fix: split by domain/variant into references. +2. **The repeat offender** — rules restated in three places. Fix: pick one canonical spot, link. +3. **The stale tool result** — old bash output sitting in context for 20 turns. Fix: summarize or drop. +4. **The silent re-read** — same file Read 5 times because model didn't track it. Fix: Read once, reference subsequent needs. +5. **The verbose example** — a 300-line example used once. Fix: condense or move to references. +6. **The unused lookup table** — a long table the skill rarely uses. Fix: move to references. +7. **The restatement of defaults** — the user (or the skill) repeats what Claude already does by default. Fix: delete; trust the defaults. + +--- + +## Part 5 — When the user pushes back + +Context engineering involves tradeoffs. Users sometimes push back: + +- **"But I want the examples in the main file so I don't have to click through."** — Fair. Ask how often the skill runs. If it's daily, the examples earn their place. If it's weekly, the cost of loading them every time doesn't pay off. +- **"But the AI might not know when to load the reference."** — Real concern. The fix is strong pointers: "When the user asks for X, read `references/x.md`." Be specific about the trigger. +- **"My window is huge, why does it matter?"** — Latency and attention. Even on a 200k window, the model's attention is finite. Information crowded out by noise is information the model misses. + +Engage with these seriously. The goal isn't minimum tokens; it's right-sized context for the task. Sometimes that's generous, sometimes tight. + +--- + +## Reference files + +- `references/token_estimation.md` — How to estimate tokens from files, messages, and tool results without running a tokenizer. +- `references/patterns.md` — Tiered context patterns: domain-organized, variant-organized, phase-organized, with examples. +- `references/anti_patterns.md` — The seven anti-patterns with diagnostic signatures and fixes. + +Read them on demand. If the user asks for a general context diagnosis, you can usually answer from SKILL.md alone. + \ No newline at end of file diff --git a/skills/context-engineer/references/anti_patterns.md b/skills/context-engineer/references/anti_patterns.md new file mode 100755 index 0000000..91a5874 --- /dev/null +++ b/skills/context-engineer/references/anti_patterns.md @@ -0,0 +1,123 @@ +# The Seven Context Anti-Patterns + +Each anti-pattern has a diagnostic signature (how to spot it), a cost profile (why it hurts), and a fix. + +## Table of contents +1. The Kitchen Sink +2. The Repeat Offender +3. The Stale Tool Result +4. The Silent Re-Read +5. The Verbose Example +6. The Unused Lookup Table +7. The Restatement of Defaults + +--- + +## 1. The Kitchen Sink + +**Signature:** one SKILL.md file that handles every domain, variant, and edge case. Often 800–2000 lines. + +**Cost:** every invocation pays the full body tax, even for the 80% of runs that only need 20% of the content. + +**Fix:** Split by variant/domain/phase into reference files (see `patterns.md`). Keep the shared workflow in SKILL.md. The body should shrink to the decision logic + the common path. + +**Example:** a video-creator SKILL.md that inlines ffmpeg slideshow instructions AND Remotion project scaffolding AND HeyGen API docs. Split into three references. + +--- + +## 2. The Repeat Offender + +**Signature:** the same instruction written in multiple places. "Use TypeScript" appears in Step 2, Step 5, and the "important notes" section. Or the output format is spec'd three times for three different cases that could share one spec. + +**Cost:** more tokens, and (worse) ambiguity — if the three statements drift apart over edits, the model has to guess which to follow. + +**Fix:** pick one canonical spot. Link or reference from other spots. + +**Example:** a SKILL.md that says "ALWAYS save output to /outputs" in four places. Pick the first, delete the rest. + +--- + +## 3. The Stale Tool Result + +**Signature:** a large tool result sitting in conversation history from 10 turns ago, no longer relevant. Common culprits: directory listings, grep results, web fetches, subagent reports. + +**Cost:** persistent token cost that grows with every tool call. Especially painful in long sessions. + +**Fix:** summarize the result after you've extracted what you need. In new turns, reference the summary instead of scrolling back to find the raw output. + +**Example:** a `find /` that returned 40,000 characters of paths, of which the user only needed 3. Summarize as: "Found the following relevant files: [path1, path2, path3]. Full listing archived in prior turn if needed." + +--- + +## 4. The Silent Re-Read + +**Signature:** the same file Read multiple times across a session because Claude didn't track that it was already in context. + +**Cost:** N copies of the file in history instead of 1. For large files, this is brutal. + +**Fix:** Read once. For subsequent references, cite the file by path/line numbers rather than re-reading. If the file has been edited mid-session, re-read is appropriate; if not, it's waste. + +**Example:** a 2,000-line source file Read 5 times in one debugging session. Fix: Read once up front, note the important functions and line numbers, reference those in subsequent turns. + +--- + +## 5. The Verbose Example + +**Signature:** a single example in SKILL.md that runs 100+ lines. Often 3–5 of them stacked. + +**Cost:** examples are valuable (better than abstract instructions), but each example is paid for on every invocation, not just when relevant. + +**Fix:** keep 1 canonical example inline. Move the rest to `references/examples.md` with a short index. The SKILL.md body says "For more examples covering X, Y, Z, read references/examples.md." + +**Example:** a copywriting skill with full before/after rewrites for 6 formats inline. Keep one format's example inline; move the other 5 to references. + +--- + +## 6. The Unused Lookup Table + +**Signature:** a long reference table (prices, codes, flags, character limits) that the skill rarely consults, but it's sitting at the top of SKILL.md. + +**Cost:** the table is paid for every invocation; the value is returned only on invocations where the skill actually needs to look something up. + +**Fix:** move the table to a reference file. The SKILL.md body says "For the full character limit table across all ad formats, read references/format_specs.md." + +**Example:** a 40-line table of Google Ads character limits. If it's checked 1 run in 5, move it. + +--- + +## 7. The Restatement of Defaults + +**Signature:** the skill (or the user) restates things Claude already does by default. "Always be helpful." "Respond in Markdown." "Use clear language." "Ask clarifying questions when unsure." + +**Cost:** tokens that convey zero new information. + +**Fix:** delete. Trust the defaults. Only write down the things that deviate from default behavior or specify the domain-specific move. + +**Example:** a SKILL.md that opens with 15 lines of "You are a helpful AI assistant. You should be clear and concise..." Delete it all. The model already knows. + +--- + +## Diagnostic workflow + +When auditing a context / skill for anti-patterns: + +1. **Scan for repeated phrases.** If "ALWAYS" appears more than 3 times, there's probably a Repeat Offender. +2. **Count lines per conceptual block.** Anything over 200 lines on a single concept is Kitchen Sink territory. +3. **Grep for example blocks.** If there's more example text than instruction text, you probably have Verbose Examples. +4. **Look for tables.** Tables over 20 rows used less than every run = Unused Lookup Tables. +5. **Look at the top 10 lines.** Is any of it restatement of default AI behavior? That's Restatement of Defaults. +6. **For live sessions:** check the last 10 tool results. Any of them over 5KB? Candidate for Stale Tool Result once they've been consumed. +7. **For live sessions:** grep the Read calls. Same path twice? Silent Re-Read. + +--- + +## What's actually worth optimizing + +Not every anti-pattern is worth fixing. Optimize where the cost is significant: + +- **A 5KB unused table in a skill used 10x/day** → fix it. 50KB/day of waste. +- **A 500-byte repeated instruction in a skill used once a month** → leave it. The juice isn't worth the squeeze. +- **A 40KB stale tool result in a 200k context** → summarize. Big, idle, easy. +- **A 2KB example repeated in a skill used 1000x/day** → cut one copy. Measurable impact. + +Context engineering has diminishing returns. Fix the big offenders first, and don't spend an hour saving 200 tokens on something rarely used. diff --git a/skills/context-engineer/references/patterns.md b/skills/context-engineer/references/patterns.md new file mode 100755 index 0000000..feae0df --- /dev/null +++ b/skills/context-engineer/references/patterns.md @@ -0,0 +1,137 @@ +# Tiered Context Patterns + +Three organizing principles for splitting a monolithic SKILL.md into a tiered structure. Pick the one that matches how the skill's variation falls. + +## Pattern 1 — Domain-organized + +Use when the skill supports multiple domains or verticals and each has its own deep playbook. + +**Example — a real-estate content skill that serves multiple markets:** + +``` +real-estate-content/ +├── SKILL.md (workflow + market selection) +└── references/ + ├── bay_area.md (local nuance for SF, Peninsula, EPA) + ├── austin.md (local nuance for Austin TX) + └── nyc.md (local nuance for NYC) +``` + +**Pattern:** SKILL.md asks the user which market (or infers it from inputs), then reads only the matching reference. The other market files never enter context. + +**When this pattern fits:** the shared workflow is 60%+ of the job and the rest is market/vertical nuance. Each domain file is self-contained. + +--- + +## Pattern 2 — Variant-organized + +Use when the skill produces different output formats / variants of the same core thing. + +**Example — a video creation skill:** + +``` +video-creator/ +├── SKILL.md (format selection + shared principles) +└── references/ + ├── mp4_slideshow.md (ffmpeg-based slideshows) + ├── remotion.md (React component-based video) + ├── heygen.md (avatar-based video) + └── stock_broll.md (higgsfield b-roll generation) +``` + +**Pattern:** SKILL.md contains a decision tree ("Is this a talking-head video? → HeyGen. Is this a slideshow? → MP4. Is this component-based? → Remotion."). Only the matching reference loads. + +**When this pattern fits:** the variants are fundamentally different technologies / output shapes, even if the user-facing request ("make me a video") looks the same. + +--- + +## Pattern 3 — Phase-organized + +Use when the skill has a long multi-phase workflow where each phase is expensive to describe. + +**Example — a CMA (comparable market analysis) skill:** + +``` +cma-generator/ +├── SKILL.md (overall workflow + phase selector) +└── references/ + ├── intake.md (gathering subject property + comp data) + ├── selection.md (which comps to include, how to filter) + ├── adjustments.md (time, size, condition adjustments) + ├── pricing.md (three-strategy pricing framework) + └── presentation.md (how to format the final report) +``` + +**Pattern:** SKILL.md is the conductor. Each phase of the workflow tells Claude which reference to load next. A full run will touch several references; a quick spot-check might only touch one. + +**When this pattern fits:** workflows where each phase is self-contained with its own rules, and where different invocations may only need a subset of phases. + +--- + +## Anti-patterns + +### The false split +Splitting a SKILL.md into references when the references are so tightly coupled they all get loaded on every run anyway. You just added Read overhead for zero benefit. + +Signal: the SKILL.md says "first read references/a.md, then references/b.md, then references/c.md" at the start of every invocation. Not a split — just a slower monolith. Put it all back together. + +### The too-fine split +Breaking a 300-line SKILL.md into 8 reference files. Each file is 30 lines and the overhead of loading them outweighs the savings. + +Rule of thumb: don't create a reference file under ~100 lines unless it's genuinely independent and only rarely needed. The minimum viable reference is usually ~200 lines. + +### The mis-organized split +Using domain organization when variant organization fits better (or vice versa). Signal: the SKILL.md selection logic is awkward — "if the user's industry is X and the format is Y..." when the split was done on industry. Usually means the split should have been on format. + +--- + +## Designing the SKILL.md "dispatcher" + +The SKILL.md body in a tiered system has one main job: route to the right reference. + +Good dispatcher patterns: + +**Explicit routing table:** +```markdown +## Which reference to read + +| User says | Read | +|---|---| +| "make me a slideshow" | references/mp4_slideshow.md | +| "React video" or "Remotion" | references/remotion.md | +| "avatar video" or "HeyGen" | references/heygen.md | +``` + +**Decision tree in prose:** +```markdown +## Pick the format + +Ask yourself: +1. Is this a talking-head format with a real face? → HeyGen. Read references/heygen.md. +2. Is this a programmatic composition with React? → Remotion. Read references/remotion.md. +3. Is this a photo-based slideshow with voiceover? → MP4 slideshow. Read references/mp4_slideshow.md. +``` + +Both work. Pick whichever flows more naturally with the skill's style. + +--- + +## Mixing patterns + +Large skills sometimes benefit from two levels of organization: + +``` +real-estate-content/ +├── SKILL.md +└── references/ + ├── markets/ + │ ├── bay_area.md + │ └── austin.md + └── formats/ + ├── listing_video.md + └── market_update.md +``` + +The dispatcher reads one from `markets/` and one from `formats/`. More complexity, but scales if you have >5 in each axis. + +Don't do this for skills with <3 entries in an axis. It's overkill. diff --git a/skills/context-engineer/references/token_estimation.md b/skills/context-engineer/references/token_estimation.md new file mode 100755 index 0000000..1914e35 --- /dev/null +++ b/skills/context-engineer/references/token_estimation.md @@ -0,0 +1,105 @@ +# Token Estimation — Without Running a Tokenizer + +You usually won't have a live tokenizer in the loop, but you can estimate token counts within ±15% using simple heuristics. Good enough for diagnostics. + +## The rules of thumb + +### Prose (English) +- **1 token ≈ 4 characters** (including spaces and punctuation) +- **1 token ≈ 0.75 words** +- A typical paragraph (80 words) ≈ 100–110 tokens +- A page of prose (~500 words) ≈ 650–700 tokens +- Typical chat message (200 words) ≈ 250–270 tokens + +### Code +Code tokenizes differently — more symbols, shorter identifiers, more punctuation. +- **Python / JavaScript / TypeScript:** ~2 characters per token (half of prose) +- A 100-line Python file (~2,500 chars) ≈ 1,200–1,400 tokens +- A 1,000-line codebase file ≈ 12,000–15,000 tokens + +### JSON and structured data +Punctuation heavy; lots of repeated keys. +- **~3 characters per token** +- A 10 KB JSON blob ≈ 3,000–3,500 tokens +- A typical MCP tool result (list of records) is surprisingly expensive — a 100-record list with 10 fields each can run 8,000+ tokens + +### Markdown +Close to prose, slightly higher due to syntax. +- **~3.5 characters per token** +- A 500-line SKILL.md (assume 50 chars/line average) ≈ 7,000 tokens + +### URLs and paths +Very dense. +- **~2 characters per token** +- A long URL with query strings can be 30–50 tokens on its own + +### Non-English languages +Much higher token-per-character ratios. +- Japanese / Chinese / Korean: 1 token ≈ 1 character +- Many European languages: slightly higher than English (accented chars, compound words) + +## Estimating from disk + +For a file on disk, the quick formula: + +``` +estimated_tokens = file_size_in_bytes / cpb +``` + +where `cpb` (characters per byte) is: +- Prose: 4 +- Code: 2 +- JSON/YAML: 3 +- Markdown: 3.5 + +In bash: +```bash +# Quick token estimate for a file +wc -c path/to/file | awk '{print "~"int($1/4)" tokens (prose)"; print "~"int($1/2)" tokens (code)"}' +``` + +## Estimating a conversation + +For a whole conversation including system prompt and history, sum these: + +1. **System prompt** — if you can see its length in characters, divide by 4 +2. **Each user message** — count roughly 100 tokens per short message, 500 per long one +3. **Each assistant message** — same math, but assistant messages tend to be longer +4. **Tool results** — treat as JSON (÷3) if structured, prose (÷4) if text +5. **Loaded files** — see "from disk" above + +Double your estimate if the system prompt looks unusually large (includes many tool definitions, user preferences, skill index). Modern Claude agent system prompts run 5–20k tokens before you add anything. + +## What token counts look like in practice + +For reference, these are typical sizes for common things: + +| Thing | Tokens | +|---|---| +| Short chat message ("what's the weather?") | 10–15 | +| Typical user request (3–4 sentences) | 50–100 | +| Long user message (1 paragraph of context) | 200–400 | +| Claude's typical response (1–2 paragraphs) | 200–500 | +| A SKILL.md frontmatter (name + description) | 100–300 | +| A SKILL.md body (500 lines, markdown) | 6,000–8,000 | +| A reference file (300 lines) | 3,500–5,000 | +| A bash `ls -la ~` output | 500–2,000 | +| A bash `find /` output | can be 50,000+ | +| A web fetch of a standard page | 3,000–15,000 | +| A PDF extracted to text (10 pages) | 3,000–5,000 | +| A 50KB JSON blob | 15,000–18,000 | + +## The "should I summarize?" threshold + +Rule of thumb: if an item takes more than **5% of your remaining context budget** to keep around, and it's not actively being referenced, summarize it. + +Example: 200k window, you're at 150k used, remaining = 50k. 5% of 50k = 2,500 tokens. Anything over ~10KB of content sitting idle should be summarized or dropped. + +## Caveats + +These are estimates, not ground truth. Actual tokenization varies: +- Anthropic's tokenizer treats some English words as single tokens while splitting others +- Emoji and special characters can each be multiple tokens +- Code with lots of identifiers in one style (camelCase vs snake_case) tokenizes differently + +For diagnostics, estimates are plenty. If you need exactness, run the text through a tokenizer (e.g., `tiktoken` for OpenAI models, Anthropic's SDK tokenizer for Claude). diff --git a/skills/contract-estimate-builder/SKILL.md b/skills/contract-estimate-builder/SKILL.md new file mode 100644 index 0000000..239d894 --- /dev/null +++ b/skills/contract-estimate-builder/SKILL.md @@ -0,0 +1,196 @@ +--- +name: contract-estimate-builder +description: "Contract Estimate Builder for Graeham Watts. Turns a plain-language list of property work tasks into a clean Excel bid sheet (with auto-calculating formulas) plus a PDF scope of work (with a comprehensive courtesy disclaimer) that can be emailed to a contractor for pricing or shared with a client. Trigger ANY time the user mentions: contract estimate, contractor bid, scope of work, SOW, bid sheet, contractor quote, send to contractor for pricing, itemize a job, itemize landscaping/repairs, write up the scope, punch list, prep list, listing prep scope, vendor scope, handyman/landscape/painter/cleaning scope, bid request, or RFQ. Also trigger when the user describes work tasks at a property and wants them organized for a contractor to price, mentions alternative options that need separate pricing (bark vs. flagstone vs. mulch), or says 'put this in a spreadsheet for my contractor.' Supports multi-option line items with a separate grand total per scenario." +--- + +# Contract Estimate Builder + +You are a contract estimate builder working alongside Graeham Watts (REALTOR, Intero Real Estate, DRE# 01466876) and his team. Graeham coordinates contractors to prep homes for sale, and his contractors are often busy guys in the field who don't compile formal scopes themselves. This skill takes Graeham's spoken/written description of the work and turns it into a clean itemized estimate that: + +1. The **contractor** can fill in with pricing and return +2. The **client** (usually the seller) can read and understand +3. Graeham's **assistant** can text or email out to the contractor for quoting + +The two outputs are: +- **Excel bid sheet** (.xlsx) — editable, with formulas that auto-calculate totals including alternative-option scenarios +- **PDF scope of work** (.pdf) — polished, presentation-ready, suitable for emailing or printing + +Both outputs always get generated unless Graeham explicitly says otherwise. + +--- + +## When to Trigger + +Trigger any time Graeham (or his assistant) describes a list of work items for a property and wants it formatted for a contractor or client. The cue is usually one of: + +- "Itemize this for [contractor]" +- "Build a scope for [property address]" +- "Send this to my landscaper / painter / handyman" +- "Make this look professional" +- A bulleted/spoken list of tasks at a property + +If the user mentions "options" — like "for the middle, we could do bark, flagstone, or mulch" — that's a signal to use the **option group** pattern (see below). Don't bury options inside a single line; break them out so each can be priced. + +--- + +## Step 1: Intake — Get the Inputs + +Before generating anything, you need: + +### Required +1. **Property address** — for the header and filenames. If Graeham only gives a street name, ask for the city too (East Palo Alto, Redwood City, Palo Alto, etc.). Always confirm spelling — Menalto Avenue in EPA is commonly misspelled as "Minalto" by autocomplete and dictation tools. +2. **Work scope** — the tasks themselves. Usually Graeham provides these as a bulleted list or in conversation. Parse them into individual line items. + +### Conditional — Ask if Not Provided +3. **Contractor name** — Ask: "Do you have the contractor's name?" If yes, fill it in. If no, leave a blank field labeled `Contractor: __________________` on the PDF and an empty cell in the Excel. +4. **Client / seller name** — Ask: "Do you have the seller's name?" If yes, fill it in. If no, leave blank similarly. +5. **Trade category** — landscaping, painting, cleaning, handyman, electrical, plumbing, general prep, etc. Used for the document title. If unclear, infer from the scope. +6. **Date** — defaults to today unless specified. + +### Don't Over-Ask +If Graeham just dropped a list of tasks with the address and said "build the estimate" — go. Don't make him answer four questions before you produce anything. Generate with reasonable defaults (today's date, blank contractor/client fields) and let him fill gaps after he sees the draft. + +--- + +## Step 2: Structure the Scope + +Convert Graeham's plain-language tasks into a structured spec. The internal format uses **base items** and **option groups**: + +### Base Items (always included in the job) +These are tasks the contractor will do regardless of which options the client picks. Each base item has: +- A short task name (e.g., "Trim trees around exterior") +- An optional longer description / clarifying notes +- A blank pricing cell for the contractor to fill in + +### Option Groups (pick-one alternatives) +When Graeham mentions multiple ways to do one portion of the work, that's an option group. Each option group has: +- A group label (e.g., "Middle yard surface treatment") +- 2 or more options (e.g., Option 1: Bark, Option 2: Flagstone with gravel, Option 3: Mulch) +- The contractor prices each option; the spreadsheet shows a separate grand total for each scenario + +**Important: don't flatten options into the base list.** If Graeham says "the middle could be bark, flagstone, or mulch," that is one option group with three options — NOT three separate base line items. Flattening it forces the contractor to price all three as if they were all being done, which is wrong. + +### Example Spec + +```json +{ + "property_address": "2247 Menalto Avenue, East Palo Alto, CA", + "trade": "Landscaping & Property Prep", + "contractor_name": null, + "client_name": null, + "date": "2026-05-14", + "base_items": [ + {"task": "Apply rock mulch around outside lawn perimeter", + "notes": "Confirm rock type with Graeham before purchasing"} + ], + "option_groups": [ + { + "label": "Middle yard surface treatment", + "notes": "Pick one. Each option is priced separately so client can compare.", + "options": [ + {"name": "Option 1: Bark", "notes": "Standard bark mulch"}, + {"name": "Option 2: Flagstone with gravel", "notes": "Flagstone set in DG or pea gravel"}, + {"name": "Option 3: Mulch", "notes": "Wood-chip mulch alternative"} + ] + } + ] +} +``` + +Save the spec as JSON, then hand it to `scripts/build_estimate.py`. + +--- + +## Step 3: Generate the Outputs + +Use the bundled build script — it produces both files in one call: + +```bash +python scripts/build_estimate.py +``` + +The script writes two files into ``: +- `{address-slug}-estimate.xlsx` +- `{address-slug}-estimate.pdf` + +### What the Excel Looks Like + +**Sheet 1 — "Bid Sheet"** (the working document for the contractor): + +The contractor fills in **only** the `Unit Cost` cells (blue text per the xlsx skill convention). Everything else is formulas. The contractor (or Graeham, or the client) instantly sees how each option changes the bottom line. + +**Sheet 2 — "Totals Summary"** auto-calculates a grand total for each scenario: + +| Scenario | Grand Total | +|---------------------------------------|--------------------------| +| Base only (no options selected) | = Base Total | +| Base + Option 1 (Bark) | = Base Total + Option 1 | +| Base + Option 2 (Flagstone + gravel) | = Base Total + Option 2 | +| Base + Option 3 (Mulch) | = Base Total + Option 3 | + +**Excel formatting rules** (per the xlsx skill): +- Blue text for editable input cells, black for formulas +- `Total` rows: bold, light fill background +- Grand total row in the Summary tab: bold, larger font, green fill +- Currency format `$#,##0.00` with zeros as `-` +- Frozen header row +- Auto-width columns + +### What the PDF Looks Like + +Clean, professional, one-pagish (longer if scope demands it): + +1. **Header** — Trade title, property address, date, prepared-by line +2. **Contractor / Client fields** — Two clean labeled lines (filled in if names provided, blank lines if not) +3. **Scope of Work** — Numbered list of base items, each with task name (bold) and notes (regular) +4. **Options Section** — Each option group in its own bordered block with the options listed underneath as alternatives the contractor prices separately +5. **Pricing instructions footer** — Short note: "Please return pricing per line item. Options are mutually exclusive — price each so the client can compare." +6. **Signature / acceptance lines** — Contractor signature + date, client signature + date + +**PDF styling** — neutral, no agent branding. Dark navy (#1a365d) headers, clean sans-serif, generous margins, good print quality. + +--- + +## The Courtesy Disclaimer (Always Included) + +Every estimate carries a small courtesy disclaimer at the bottom — small gray italic on the PDF, small gray text on the Excel Summary tab. It exists because Graeham is facilitating, not contracting. Without it, a realtor who hands a vendor estimate to a client can be implied as the contracting party or as warranting the vendor's quality, which is legal exposure he doesn't want. + +The default disclaimer (in `build_estimate.py` as `DEFAULT_DISCLAIMER`) covers: +- Courtesy basis — Graeham/Intero are not a party to any contractor agreement +- No warranty on pricing, scope, quality, or contractor qualifications +- No liability for performance +- Reminder to verify license/bond at `cslb.ca.gov` +- Acknowledgment that the owner may choose licensed OR unlicensed at their discretion +- Suggestion to confirm insurance, get additional bids, and review with counsel + +**Don't remove the disclaimer.** If the user wants different language for a specific deal, accept a custom version via the spec's optional `disclaimer` field — but always include something. Quiet protection that nobody reads is still protection. + +--- + +## Step 4: Deliver and Offer Next Steps + +After generating, present the files to Graeham. Always offer two natural next steps: + +1. **Email it to the contractor** — If you have a contractor email, offer to compose a Gmail draft. Subject line: `Estimate Request — [Address] — [Trade]`. Body should be short, friendly, and ask for a return-by date. + +2. **Hand off to assistant for text** — Generate a short SMS-friendly message the assistant can copy-paste: + +> "Hi [Contractor], Graeham asked me to send over the scope for [Address]. Attached PDF + Excel — please fill in pricing on the Excel and send back when you can. Thanks!" + +Don't send anything without explicit confirmation. Drafts and copy-paste messages only. + +--- + +## Edge Cases & Judgment Calls + +### Unit / Quantity Confusion +Some tasks are clearly "one job" (e.g., "power wash front and back") and others might be per-unit (e.g., "plant new bushes — qty TBD"). When unclear, default Qty to 1 and add a note like "Qty TBD with contractor." + +### Vague Tasks +If Graeham says something vague like "fix up the side yard," ask one clarifying question rather than guessing. Bad estimates come from over-interpreting. + +### Multiple Option Groups +A single estimate can have several option groups. The Summary tab shows every combination of scenarios. If there are more than ~6 combinations, the script switches from a flat list to a small grid showing each group's option columns. + +### Adding Items After the Fact +Reuse the original spec, append the line, \ No newline at end of file diff --git a/skills/contract-estimate-builder/examples/menalto-example.json b/skills/contract-estimate-builder/examples/menalto-example.json new file mode 100644 index 0000000..320b347 --- /dev/null +++ b/skills/contract-estimate-builder/examples/menalto-example.json @@ -0,0 +1,28 @@ +{ + "property_address": "2247 Menalto Avenue, East Palo Alto, CA", + "trade": "Landscaping and Property Prep", + "contractor_name": "Adrian Aboniawn", + "client_name": null, + "date": "2026-05-14", + "base_items": [ + {"task": "Apply rock mulch around outside lawn perimeter", "notes": "Despite being called mulch on the original scope, finish material is rock. Confirm rock type/size with Graeham before purchasing.", "qty": 1, "unit_cost": null}, + {"task": "Fill in missing rock in middle yard area", "notes": "Match existing rock type and color where rock is currently missing.", "qty": 1, "unit_cost": null}, + {"task": "Trim trees around exterior of property", "notes": "Shape, clear deadwood and crossing branches. Haul away all cuttings.", "qty": 1, "unit_cost": null}, + {"task": "Plant new bushes (quantity TBD)", "notes": "Suggest 2-3 medium evergreen shrubs to fill bare spots. Confirm species and count with contractor.", "qty": 1, "unit_cost": null}, + {"task": "Power wash front yard and backyard hardscape", "notes": "Driveway, walkways, patio, and any concrete or pavers. Aim for listing-ready appearance.", "qty": 1, "unit_cost": null}, + {"task": "Complete clean of ADU in backyard", "notes": "Full interior + exterior clean. Floors, windows, bathrooms, kitchen, all surfaces. Should look move-in ready.", "qty": 1, "unit_cost": null}, + {"task": "Remove all leaves from property", "notes": "Rake, blow, and haul away. Front, back, and side yards.", "qty": 1, "unit_cost": null}, + {"task": "Clean turf in back portion", "notes": "Brush, deep clean, refresh appearance. No replacement - cleaning only.", "qty": 1, "unit_cost": null} + ], + "option_groups": [ + { + "label": "Middle yard surface treatment", + "notes": "Pick one. Each option is priced separately so the client can compare cost vs. look.", + "options": [ + {"name": "Option 1: Bark", "notes": "Standard bark mulch. Lowest cost, refreshes easily, needs replenishing every couple years.", "qty": 1, "unit_cost": null}, + {"name": "Option 2: Flagstone with gravel", "notes": "Flagstone pieces set in decomposed granite or pea gravel. Highest cost, most polished look, longest lasting.", "qty": 1, "unit_cost": null}, + {"name": "Option 3: Mulch (wood-chip)", "notes": "Wood-chip mulch alternative to bark. Mid-cost, soft look, breaks down over time.", "qty": 1, "unit_cost": null} + ] + } + ] +} diff --git a/skills/contract-estimate-builder/scripts/build_estimate.py b/skills/contract-estimate-builder/scripts/build_estimate.py new file mode 100644 index 0000000..2186c04 --- /dev/null +++ b/skills/contract-estimate-builder/scripts/build_estimate.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +"""Contract Estimate Builder. + +Generates an Excel bid sheet + PDF scope of work from a JSON spec. + +CRITICAL: Reads brand identity (DRE, name, brokerage) from +shared-references/identity.json - never hardcoded. This is the rule across all +of Graeham's skills: identity.json is the single source of truth. +""" + +import json +import re +import sys +from datetime import date +from itertools import product +from pathlib import Path + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side + +from reportlab.lib import colors +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + HRFlowable, KeepTogether, ListFlowable, ListItem, PageBreak, + Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, +) + +# ---- Identity (read from canonical source) ---- + +def load_identity(): + """Read brand identity from shared-references/identity.json. + + Walks up from this script's location to find Skills/skills/shared-references/. + Raises a clear error if not found - we never want to fall back to hardcoded + values because that's exactly how the zombie DRE keeps reappearing. + """ + here = Path(__file__).resolve().parent + # contract-estimate-builder/scripts -> contract-estimate-builder -> skills + skills_root = here.parent.parent + identity_path = skills_root / "shared-references" / "identity.json" + if not identity_path.exists(): + raise FileNotFoundError( + "Could not find identity.json at " + str(identity_path) + + ". This skill must run from inside Skills/skills/contract-estimate-builder/. " + "Do NOT hardcode identity values - fix the path instead." + ) + with open(identity_path) as f: + data = json.load(f) + ident = data["identity"] + blocked = data.get("_blocked_values", {}).get("dre_blocklist", []) + if ident["dre"] in blocked: + raise ValueError( + "identity.json has a blocked DRE (" + ident["dre"] + + "). Stop and fix identity.json before proceeding." + ) + return ident + + +IDENTITY = load_identity() + +NAVY = "1A365D" +TEAL = "0D9488" +SLATE = "475569" +LIGHT_GRAY = "F1F5F9" +ROW_ALT = "F8FAFC" +GREEN_FILL = "DCFCE7" +BLUE_INPUT = "0000FF" +DISCLAIMER_GRAY = "64748B" +DISCLAIMER_DARK = "334155" + +CURRENCY_FMT = '"$"#,##0.00;[Red]("$"#,##0.00);"-"' +THIN = Side(border_style="thin", color="CBD5E1") +BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) + + +def agent_credit(): + """Build the prepared-by signature line from identity.json values.""" + return ( + IDENTITY["name"] + ", " + IDENTITY["title"] + + " - " + IDENTITY["brokerage"] + + " - DRE# " + IDENTITY["dre"] + ) + + +DISCLAIMER_TITLE = "DISCLAIMER AND OWNER RESPONSIBILITIES" + +DISCLAIMER_INTRO = ( + "This document is provided solely as a courtesy by " + IDENTITY["name"] + + " (" + IDENTITY["title"] + ", " + IDENTITY["brokerage"] + + ", DRE# " + IDENTITY["dre"] + ") to summarize a scope of work being " + "discussed between the property owner and a third-party contractor. It is " + "not a contract, not a binding offer, and not a recommendation. No signature " + "is required, and receipt or review of this document by any party does not " + "constitute acceptance of, or agreement to, any terms set forth in it. Any " + "agreement for work to be performed must be entered into separately and in " + "writing directly between the property owner and the contractor." +) + +DISCLAIMER_LIABILITY = ( + IDENTITY["name"] + " and " + IDENTITY["brokerage"] + " are not a party to " + "any agreement between the property owner and the contractor, are not " + "performing any of the work described, and receive no referral fee, " + "compensation, or financial benefit of any kind from the contractor. No " + "warranty - express or implied - is made as to pricing, scope, quality of " + "work, contractor licensure, contractor insurance, contractor bond status, " + "contractor qualifications, materials, timeline, code compliance, permit " + "requirements, or workmanship. " + IDENTITY["name"] + " and " + + IDENTITY["brokerage"] + " assume no liability for performance or non-" + "performance of the work, for any damage to person or property, or for any " + "dispute that may arise between the owner and the contractor." +) + +DISCLAIMER_LICENSING = ( + "Licensed or unlicensed contractor - owner accepts all risk. The contractor " + "named in this scope may or may not be a licensed contractor. " + IDENTITY["name"] + " " + "makes no representation either way and has not verified the contractor's " + "license status. The property owner accepts all risk and responsibility for " + "their choice of contractor. If the contractor IS licensed, the owner is " + "responsible for verifying that license themselves at cslb.ca.gov; " + + IDENTITY["name"] + " takes no responsibility for whether that license is " + "current, valid, or in good standing. If the contractor is NOT licensed " + "and the owner chooses to work with them anyway, that decision and all " + "consequences are the owner's alone. We always recommend obtaining " + "alternative bids if the owner does not feel comfortable with this " + "contractor for any reason." +) + + +DISCLAIMER_OWNER_LEAD = "The property owner is solely responsible for:" + +DISCLAIMER_OWNER_ITEMS = [ + "Verifying the contractor's current license status and bond at cslb.ca.gov " + "(California Contractors State License Board), or knowingly accepting an " + "unlicensed contractor at the owner's own risk. Under California Business " + "and Professions Code Section 7048, unlicensed contractors may only perform " + "work where the combined cost of labor and materials is less than $500.", + + "Confirming the contractor's general liability insurance and workers' " + "compensation coverage before any work begins.", + + "Pulling any required building permits and confirming the work complies with " + "all local building codes, HOA covenants, conditions, and restrictions, " + "easements, and zoning restrictions.", + + "Investigating any environmental factors that may apply, including but not " + "limited to lead paint (homes built before 1978), asbestos, mold, and any " + "other hazardous-material considerations.", + + "Obtaining additional bids if desired and determining for themselves whether " + "the contractor's pricing is reasonable for the work described.", + + "Inspecting completed work, accepting or rejecting completion, and resolving " + "any disputes directly with the contractor.", + + "All other investigation, due diligence, follow-up, and research relating to " + "the contractor, the scope, and the work to be performed. " + + IDENTITY["name"] + " is not conducting any such investigation or due " + "diligence on behalf of the owner.", + + "Consulting their own independent legal counsel before signing any contract " + "or making any payment.", +] + +DISCLAIMER_ACK = ( + "By receiving this document, the property owner and contractor each " + "acknowledge that this is an informational scope summary only, that it " + "does not constitute a contract or agreement between any parties, and that " + "no party is bound by anything contained in it until and unless a separate " + "written agreement is signed." +) + + +def slugify(s): + s = s.lower().strip() + s = re.sub(r"[^a-z0-9]+", "-", s) + return s.strip("-")[:60] or "estimate" + + +def fmt_date(d): + return d or date.today().isoformat() + + +def excel_disclaimer_text(): + parts = [DISCLAIMER_TITLE, "", DISCLAIMER_INTRO, "", + DISCLAIMER_LIABILITY, "", DISCLAIMER_LICENSING, "", + DISCLAIMER_OWNER_LEAD] + for item in DISCLAIMER_OWNER_ITEMS: + parts.append("- " + item) + parts.append("") + parts.append(DISCLAIMER_ACK) + return "\n".join(parts) + + +def build_excel(spec, out_path): + wb = Workbook() + ws = wb.active + ws.title = "Bid Sheet" + + ws["A1"] = "CONTRACT ESTIMATE - " + spec.get("trade", "Scope of Work").upper() + ws["A1"].font = Font(bold=True, size=14, color=NAVY) + ws.merge_cells("A1:F1") + + ws["A2"] = "Property: " + spec["property_address"] + ws["A3"] = "Date: " + fmt_date(spec.get("date")) + ws["A4"] = "Contractor: " + (spec.get("contractor_name") or "____________________________") + ws["A5"] = "Client: " + (spec.get("client_name") or "____________________________") + for r in range(2, 6): + ws["A" + str(r)].font = Font(size=10, color=SLATE) + ws.merge_cells("A" + str(r) + ":F" + str(r)) + + header_row = 7 + for col_idx, h in enumerate(["#", "Task", "Notes", "Qty", "Unit Cost", "Line Total"], start=1): + c = ws.cell(row=header_row, column=col_idx, value=h) + c.font = Font(bold=True, color="FFFFFF") + c.fill = PatternFill("solid", start_color=NAVY) + c.alignment = Alignment(horizontal="center", vertical="center") + c.border = BORDER + + row = header_row + 1 + base_first = row + for i, item in enumerate(spec.get("base_items", []), start=1): + unit_cost = item.get("unit_cost") + cells = [ + i, item.get("task", ""), item.get("notes", "") or "", + item.get("qty", 1), + unit_cost if unit_cost is not None else None, + "=D" + str(row) + "*E" + str(row), + ] + for col_idx, val in enumerate(cells, start=1): + c = ws.cell(row=row, column=col_idx, value=val) + c.border = BORDER + if col_idx == 1: c.alignment = Alignment(horizontal="center") + elif col_idx in (2, 3): c.alignment = Alignment(wrap_text=True, vertical="top") + elif col_idx in (4, 5, 6): c.alignment = Alignment(horizontal="right") + if col_idx in (5, 6): c.number_format = CURRENCY_FMT + if col_idx == 5: c.font = Font(color=BLUE_INPUT) + if row % 2 == 0: c.fill = PatternFill("solid", start_color=ROW_ALT) + row += 1 + base_last = row - 1 + + base_total_row = row + ws.cell(row=row, column=2, value="BASE TOTAL").font = Font(bold=True) + ws.cell(row=row, column=6, + value="=SUM(F" + str(base_first) + ":F" + str(base_last) + ")").number_format = CURRENCY_FMT + for col_idx in range(1, 7): + c = ws.cell(row=row, column=col_idx) + c.fill = PatternFill("solid", start_color=LIGHT_GRAY) + c.font = Font(bold=True) + c.border = BORDER + row += 2 + + option_group_rows = [] + for g_idx, group in enumerate(spec.get("option_groups", []), start=1): + ws.cell(row=row, column=1, value="OPTIONS - Group " + str(g_idx)) + ws.cell(row=row, column=2, value=group.get("label", "")) + ws.cell(row=row, column=3, value=group.get("notes", "Pick one.")) + for col_idx in range(1, 7): + c = ws.cell(row=row, column=col_idx) + c.fill = PatternFill("solid", start_color=NAVY) + c.font = Font(bold=True, color="FFFFFF") + c.border = BORDER + ws.merge_cells(start_row=row, start_column=3, end_row=row, end_column=6) + row += 1 + + rows_for_group = [] + for o_idx, opt in enumerate(group.get("options", []), start=1): + unit_cost = opt.get("unit_cost") + cells = [ + str(g_idx) + "." + str(o_idx), opt.get("name", ""), + opt.get("notes", "") or "", opt.get("qty", 1), + unit_cost if unit_cost is not None else None, + "=D" + str(row) + "*E" + str(row), + ] + for col_idx, val in enumerate(cells, start=1): + c = ws.cell(row=row, column=col_idx, value=val) + c.border = BORDER + if col_idx == 1: c.alignment = Alignment(horizontal="center") + elif col_idx in (2, 3): c.alignment = Alignment(wrap_text=True, vertical="top") + elif col_idx in (4, 5, 6): c.alignment = Alignment(horizontal="right") + if col_idx in (5, 6): c.number_format = CURRENCY_FMT + if col_idx == 5: c.font = Font(color=BLUE_INPUT) + c.fill = PatternFill("solid", start_color=GREEN_FILL if row % 2 == 0 else "ECFCCB") + rows_for_group.append((opt.get("name", "Option " + str(o_idx)), row)) + row += 1 + option_group_rows.append({"label": group.get("label", "Group " + str(g_idx)), "options": rows_for_group}) + row += 1 + + for col, w in {"A": 6, "B": 36, "C": 38, "D": 8, "E": 14, "F": 16}.items(): + ws.column_dimensions[col].width = w + ws.freeze_panes = ws.cell(row=header_row + 1, column=1) + + s2 = wb.create_sheet("Totals Summary") + s2["A1"] = "TOTALS SUMMARY - Grand Total by Scenario" + s2["A1"].font = Font(bold=True, size=14, color=NAVY) + s2.merge_cells("A1:C1") + s2["A2"] = "Property: " + spec["property_address"] + s2["A2"].font = Font(size=10, color=SLATE) + s2.merge_cells("A2:C2") + + s2["A4"] = "Scenario"; s2["B4"] = "Components"; s2["C4"] = "Grand Total" + for col_idx in range(1, 4): + c = s2.cell(row=4, column=col_idx) + c.font = Font(bold=True, color="FFFFFF") + c.fill = PatternFill("solid", start_color=NAVY) + c.alignment = Alignment(horizontal="center") + c.border = BORDER + + s_row = 5 + s2.cell(row=s_row, column=1, value="Base only (no options)") + s2.cell(row=s_row, column=2, value="Base Total") + s2.cell(row=s_row, column=3, value="='Bid Sheet'!F" + str(base_total_row)).number_format = CURRENCY_FMT + for col_idx in range(1, 4): + s2.cell(row=s_row, column=col_idx).border = BORDER + s_row += 1 + + if option_group_rows: + combos = list(product(*[g["options"] for g in option_group_rows])) + if len(combos) <= 12: + for combo in combos: + labels = " + ".join(n for n, _ in combo) + comps = "Base + " + " + ".join(n for n, _ in combo) + parts = ["'Bid Sheet'!F" + str(base_total_row)] + ["'Bid Sheet'!F" + str(rn) for _, rn in combo] + formula = "=" + "+".join(parts) + s2.cell(row=s_row, column=1, value="Base + " + labels) + s2.cell(row=s_row, column=2, value=comps) + s2.cell(row=s_row, column=3, value=formula).number_format = CURRENCY_FMT + for col_idx in range(1, 4): + s2.cell(row=s_row, column=col_idx).border = BORDER + if s_row % 2 == 0: + s2.cell(row=s_row, column=col_idx).fill = PatternFill("solid", start_color=ROW_ALT) + s_row += 1 + else: + for g in option_group_rows: + s2.cell(row=s_row, column=1, value="-- " + g["label"] + " options --").font = Font(bold=True, italic=True) + s_row += 1 + for n, rn in g["options"]: + s2.cell(row=s_row, column=1, value="Base + " + n) + s2.cell(row=s_row, column=2, value="Base + " + n) + s2.cell(row=s_row, column=3, value="='Bid Sheet'!F" + str(base_total_row) + "+'Bid Sheet'!F" + str(rn)).number_format = CURRENCY_FMT + for col_idx in range(1, 4): + s2.cell(row=s_row, column=col_idx).border = BORDER + s_row += 1 + + for r in range(5, s_row): + s2.cell(row=r, column=3).font = Font(bold=True) + s2.column_dimensions["A"].width = 42 + s2.column_dimensions["B"].width = 48 + s2.column_dimensions["C"].width = 18 + s2.freeze_panes = s2.cell(row=5, column=1) + + note_row = s_row + 2 + s2.cell(row=note_row, column=1, value="How to use this:").font = Font(bold=True) + s2.cell(row=note_row + 1, column=1, value="- Contractor fills in Unit Cost on the Bid Sheet tab (blue cells).").font = Font(size=10, color=SLATE) + s2.cell(row=note_row + 2, column=1, value="- Grand Total for each scenario above updates automatically.").font = Font(size=10, color=SLATE) + s2.cell(row=note_row + 3, column=1, value="- Options are mutually exclusive - pick ONE per group.").font = Font(size=10, color=SLATE) + for r in range(note_row + 1, note_row + 4): + s2.merge_cells(start_row=r, start_column=1, end_row=r, end_column=3) + + disc_row = note_row + 5 + c = s2.cell(row=disc_row, column=1, value=excel_disclaimer_text()) + c.font = Font(size=8, color=DISCLAIMER_DARK, italic=True) + c.alignment = Alignment(wrap_text=True, vertical="top") + s2.merge_cells(start_row=disc_row, start_column=1, end_row=disc_row, end_column=3) + s2.row_dimensions[disc_row].height = 320 + + wb.save(out_path) + + +def build_pdf(spec, out_path): + doc = SimpleDocTemplate( + str(out_path), pagesize=letter, + leftMargin=0.7 * inch, rightMargin=0.7 * inch, + topMargin=0.6 * inch, bottomMargin=0.6 * inch, + title="Contract Estimate - " + spec["property_address"], + author=IDENTITY["name"] + ", " + IDENTITY["brokerage"], + ) + styles = getSampleStyleSheet() + h1 = ParagraphStyle("h1", parent=styles["Heading1"], textColor=colors.HexColor("#" + NAVY), fontSize=18, leading=22, spaceAfter=4) + h2 = ParagraphStyle("h2", parent=styles["Heading2"], textColor=colors.HexColor("#" + NAVY), fontSize=13, leading=16, spaceBefore=14, spaceAfter=6) + meta = ParagraphStyle("meta", parent=styles["Normal"], textColor=colors.HexColor("#" + SLATE), fontSize=10, leading=13) + body = ParagraphStyle("body", parent=styles["Normal"], fontSize=10, leading=14) + task_title = ParagraphStyle("task_title", parent=styles["Normal"], fontSize=10.5, leading=14, fontName="Helvetica-Bold") + task_notes = ParagraphStyle("task_notes", parent=styles["Normal"], fontSize=9.5, leading=12, textColor=colors.HexColor("#" + SLATE)) + option_label = ParagraphStyle("opt", parent=styles["Normal"], fontSize=11, leading=14, fontName="Helvetica-Bold", textColor=colors.HexColor("#" + TEAL)) + disc_h = ParagraphStyle("disc_h", parent=styles["Normal"], fontSize=10, leading=13, fontName="Helvetica-Bold", textColor=colors.HexColor("#" + DISCLAIMER_DARK), spaceBefore=10, spaceAfter=4) + disc_body = ParagraphStyle("disc_body", parent=styles["Normal"], fontSize=8, leading=11, textColor=colors.HexColor("#" + DISCLAIMER_DARK), alignment=4, spaceAfter=6) + disc_bullet = ParagraphStyle("disc_bullet", parent=disc_body, fontSize=8, leading=11, leftIndent=12, spaceAfter=3) + disc_ack = ParagraphStyle("disc_ack", parent=disc_body, fontSize=8, leading=11, fontName="Helvetica-Oblique", textColor=colors.HexColor("#" + DISCLAIMER_DARK)) + + story = [] + story.append(Paragraph("CONTRACT ESTIMATE - " + spec.get("trade", "Scope of Work").upper(), h1)) + story.append(Paragraph("Property: " + spec["property_address"], meta)) + story.append(Paragraph("Date: " + fmt_date(spec.get("date")), meta)) + contractor = spec.get("contractor_name") or "____________________________" + client = spec.get("client_name") or "____________________________" + story.append(Paragraph("Contractor: " + contractor, meta)) + story.append(Paragraph("Client: " + client, meta)) + story.append(Spacer(1, 6)) + story.append(HRFlowable(width="100%", thickness=0.6, color=colors.HexColor("#" + NAVY))) + story.append(Spacer(1, 4)) + + instr = Table([[Paragraph( + "Instructions: Please return pricing per line item on the attached Excel bid sheet. " + "Items below are the proposed base scope. Where alternative options are listed, " + "price each option separately - the client will pick one. The Excel auto-calculates " + "a grand total for each option scenario. No signature is required on this document - " + "it is a scope summary, not a contract.", + body)]], colWidths=[7.0 * inch]) + instr.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#" + LIGHT_GRAY)), + ("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#" + SLATE)), + ("LEFTPADDING", (0, 0), (-1, -1), 10), ("RIGHTPADDING", (0, 0), (-1, -1), 10), + ("TOPPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ])) + story.append(instr) + + story.append(Paragraph("Base Scope of Work", h2)) + base_data = [["#", "Task", "Notes", "Unit Price"]] + for i, item in enumerate(spec.get("base_items", []), start=1): + base_data.append([ + str(i), + Paragraph(item.get("task", ""), task_title), + Paragraph(item.get("notes", "") or "", task_notes), + "$ _______________" if item.get("unit_cost") is None else "$" + format(item.get("unit_cost"), ",.2f"), + ]) + bt = Table(base_data, colWidths=[0.4 * inch, 2.6 * inch, 2.8 * inch, 1.4 * inch], repeatRows=1) + bt.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#" + NAVY)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 9.5), + ("ALIGN", (0, 0), (0, -1), "CENTER"), + ("ALIGN", (3, 0), (3, -1), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#" + ROW_ALT)]), + ("BOX", (0, 0), (-1, -1), 0.6, colors.HexColor("#" + SLATE)), + ("INNERGRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#E2E8F0")), + ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ])) + story.append(bt) + + if spec.get("option_groups"): + story.append(Paragraph("Alternative Options (Pick One Per Group)", h2)) + for g_idx, group in enumerate(spec["option_groups"], start=1): + block = [Paragraph("Group " + str(g_idx) + ": " + group.get("label", ""), option_label)] + if group.get("notes"): + block.append(Paragraph(group["notes"], task_notes)) + block.append(Spacer(1, 4)) + opt_data = [["#", "Option", "Notes", "Unit Price"]] + for o_idx, opt in enumerate(group.get("options", []), start=1): + opt_data.append([ + str(g_idx) + "." + str(o_idx), + Paragraph(opt.get("name", ""), task_title), + Paragraph(opt.get("notes", "") or "", task_notes), + "$ _______________" if opt.get("unit_cost") is None else "$" + format(opt.get("unit_cost"), ",.2f"), + ]) + ot = Table(opt_data, colWidths=[0.5 * inch, 2.5 * inch, 2.8 * inch, 1.4 * inch], repeatRows=1) + ot.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#" + TEAL)), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 9.5), + ("ALIGN", (0, 0), (0, -1), "CENTER"), + ("ALIGN", (3, 0), (3, -1), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#" + GREEN_FILL)]), + ("BOX", (0, 0), (-1, -1), 0.6, colors.HexColor("#" + TEAL)), + ("INNERGRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#A7F3D0")), + ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ])) + block.append(ot) + block.append(Spacer(1, 8)) + story.append(KeepTogether(block)) + + story.append(PageBreak()) + story.append(Paragraph(DISCLAIMER_TITLE, disc_h)) + story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#" + DISCLAIMER_GRAY))) + story.append(Spacer(1, 6)) + story.append(Paragraph(DISCLAIMER_INTRO, disc_body)) + story.append(Paragraph(DISCLAIMER_LIABILITY, disc_body)) + story.append(Paragraph(DISCLAIMER_LICENSING, disc_body)) + story.append(Paragraph("" + DISCLAIMER_OWNER_LEAD + "", disc_body)) + bullets = [ListItem(Paragraph(item, disc_bullet), leftIndent=12, bulletColor=colors.HexColor("#" + DISCLAIMER_DARK)) + for item in DISCLAIMER_OWNER_ITEMS] + story.append(ListFlowable(bullets, bulletType="bullet", start="circle", leftIndent=14, bulletFontSize=7)) + story.append(Spacer(1, 6)) + story.append(Paragraph("" + DISCLAIMER_ACK + "", disc_ack)) + story.append(Spacer(1, 10)) + + story.append(Paragraph("Optional - Acknowledgment of receipt (not required, not a contract):", disc_body)) + sig = Table([ + ["Property owner signature", "Date"], + ["_________________________________", "______________"], + ["", ""], + ["Contractor signature", "Date"], + ["_________________________________", "______________"], + ], colWidths=[4.5 * inch, 2.5 * inch]) + sig.setStyle(TableStyle([ + ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#" + SLATE)), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), ("TOPPADDING", (0, 0), (-1, -1), 4), + ])) + story.append(sig) + story.append(Spacer(1, 14)) + story.append(Paragraph("Prepared by " + agent_credit() + "", + ParagraphStyle("footer", parent=styles["Normal"], fontSize=8, + textColor=colors.HexColor("#" + SLATE), alignment=1))) + doc.build(story) + + +def main(): + if len(sys.argv) < 3: + print("Usage: python build_estimate.py ") + sys.exit(1) + spec = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + out_dir = Path(sys.argv[2]) + out_dir.mkdir(parents=True, exist_ok=True) + slug = slugify(spec["property_address"].split(",")[0]) + xlsx = out_dir / (slug + "-estimate.xlsx") + pdf = out_dir / (slug + "-estimate.pdf") + build_excel(spec, xlsx) + build_pdf(spec, pdf) + print("Wrote:", xlsx) + print("Wrote:", pdf) + + +if __name__ == "__main__": + main() diff --git a/skills/copywriter/SKILL.md b/skills/copywriter/SKILL.md new file mode 100755 index 0000000..4df1898 --- /dev/null +++ b/skills/copywriter/SKILL.md @@ -0,0 +1,185 @@ +--- +name: copywriter +description: Direct-response copywriter that produces high-converting marketing copy in any format — ad headlines, landing page hero copy, email subject lines, sales pages, CTAs, product descriptions, social captions. Picks the right framework (AIDA for cma-reports/newsletters/ads, PAS for problem-aware audiences, FAB for feature-heavy products, Before/After/Bridge for transformation offers), delivers three variations by default, and explains the psychological lever each one pulls. Use ANY time the user mentions copywriting, copywriter, write me a headline, write me copy, ad copy, Facebook ad, Google ad, landing page copy, hero section, email subject line, cold email, CTA, call to action, sales copy, sales page, product description, conversion copy, VSL, marketing copy, brand voice, tagline, slogan, or promo copy. Also trigger when the user pastes a product and asks "how would you pitch this?", shares a weak CTA, or wants A/B variations. Over-trigger — any "help me sell this" moment is copywriting. +--- + +# Copywriter — Direct-Response Engine + +You are a direct-response copywriter. You write words designed to move a specific audience from awareness to action. You pick the framework that fits the job, you write three variations, and you explain why each one works. + +This skill is about craft, not just vibes. Every piece of copy is a hypothesis about the reader: who they are, what they want, what's stopping them, and what will unstick them. Good copy is that hypothesis, sharpened. + +--- + +## Step 1 — Get the minimum inputs + +Before you write, you need three things: + +1. **What's being sold** — the product, service, or offer. A sentence or two is enough. +2. **Who's being sold to** — the target audience. Who they are, what they want, where they are in their journey (cold / warm / hot). +3. **What format** — the specific deliverable the user needs: ad headline, email subject line, landing page hero, CTA, sales page section, product description, social caption, VSL opener, etc. + +If any of these three are missing, ask. Don't guess. Ask concisely, one question at a time if needed, and move on the moment you have enough to work. + +**Example clarifying prompts:** +- "Who's this for? Describe the reader in one sentence — their role, their situation, what they're trying to do." +- "What format do you want — a headline, an email subject line, a full landing page hero section, something else?" +- "Is this audience cold (doesn't know the problem yet), warm (aware of the problem, shopping for solutions), or hot (close to buying, just needs the nudge)?" + +If the user has given you enough, don't stall. Write. + +--- + +## Step 2 — Pick the right framework + +The framework is a shortcut to the right structure. Pick based on the format and the audience's awareness level. + +| Framework | Best for | Why | +|---|---|---| +| **AIDA** — Attention → Interest → Desire → Action | Email subject lines, ads, cold outreach, short landing page heroes | Works when you have one moment to earn the next moment. Linear, punchy, CTA-focused. | +| **PAS** — Problem → Agitate → Solution | Problem-aware audiences, sales pages for painful problems | The reader already knows they have a problem. Naming it and twisting the knife earns trust faster than any feature list. | +| **FAB** — Features → Advantages → Benefits | Feature-heavy products (SaaS, physical products, technical tools) | Forces you to translate specs into what they do (advantages) into what the reader gains (benefits). The only way to sell complicated things. | +| **BAB** — Before → After → Bridge | Transformation-based offers (coaching, courses, health, career) | Reader is buying a future state, not a product. Show the gap and position the offer as the bridge. | + +If none of these feel right, write in plain direct-response voice: concrete, specific, short sentences, lead with the benefit. Read `references/frameworks.md` for deeper examples and less common frameworks (4Us, 4Cs, PASTOR, Storybrand) if the job calls for something more specialized. + +--- + +## Step 3 — Write three variations + +Three is the sweet spot. One is a swing and a miss risk. Five dilutes. Three lets the user pick, combine, or use as a starting point for their own edits. + +Each variation should: +- **Pull a different lever.** Don't write three versions of the same angle — write three angles. One might lead with loss aversion, one with social proof, one with curiosity. Variety is the point. +- **Be finished, not a draft.** Every variation should be copy-paste-ready at its intended length. +- **Come with a short "why."** One or two sentences explaining the psychological mechanism. Examples: + - "Loss aversion — frames the price as 'not losing your weekends' rather than a cost." + - "Curiosity gap — the subject line implies information the reader doesn't have yet." + - "Specificity builds trust — the number '23%' reads more real than 'significant improvement.'" + +The "why" is what makes this a copywriter, not a generator. It shows the user the move so they can do it themselves next time. + +--- + +## Step 4 — Output format + +Always use this structure: + +``` +## Framework +[Name of framework + one-sentence justification — why this fit] + +## Variation 1 — [lever name, e.g., "Loss aversion"] +[The copy. Clean, finished, ready to paste.] + +**Why it works:** [one to two sentences on the psychological mechanism] + +## Variation 2 — [lever name] +[The copy.] + +**Why it works:** [explanation] + +## Variation 3 — [lever name] +[The copy.] + +**Why it works:** [explanation] + +## Pick / Combine +[One line: which variation you'd ship and why, or how to combine the best bits. The user asked for a recommendation by default — give it.] +``` + +For formats with length constraints (email subject lines: 40–60 chars; Google Ads headlines: 30 chars; meta descriptions: ~155 chars), note the character count after each variation. + +--- + +## Step 5 — Mandatory final pass: humanizer + +Before you deliver the three variations, run every piece of copy through the `humanizer` skill. Direct-response copy is the worst place to leak AI patterns — readers can smell "stands as a testament" or em-dash overload in two seconds and the conversion rate dies with it. + +**What gets humanized:** +- Every headline, subhead, body line, and CTA in all three variations +- The "Why it works" rationales (clients read these too) +- The "Pick / Combine" recommendation line + +**What does NOT get humanized:** +- Character counts and length notes (numerical metadata) +- The framework justification line (technical reference) + +**How to invoke:** +1. Generate the three variations and rationales as usual. +2. Pass the full prose block to the humanizer skill with a one-line voice note (e.g., "B2B SaaS, confident, no jargon" or "DTC, warm, sensory"). +3. Replace the original copy with the humanized version before assembling the final output structure. +4. Deliver. + +If the user has supplied a brand voice sample at intake, hand it to the humanizer as the voice-calibration sample so the rewrite matches their tone rather than the default humanizer voice. + +This step is non-negotiable. Ad copy that sounds like a model wrote it does not convert. + +--- + +## The psychological levers — a toolkit + +These are the moves good direct-response copy makes. Mix and match across your three variations: + +- **Loss aversion** — fear of losing is 2x stronger than desire to gain. "Stop losing deals to follow-up gaps" beats "Win more deals with better follow-up." +- **Social proof** — "Used by 47,000 agencies" / "4.9 stars from 2,000 reviews" / "As seen in..." +- **Specificity** — specific numbers and details read as true. "Closed $2.3M in Q2" beats "had a great quarter." +- **Curiosity gap** — imply information the reader needs but doesn't have. "The email trick that booked us 3 demos last week." +- **Status / identity** — "For founders who actually ship" / "Designed for operators, not tourists." +- **Urgency / scarcity** — real, not manufactured. Deadlines, limited runs, calendar slots. +- **Contrast** — before/after, us/them, old way/new way. The mind reads in contrast. +- **Speed / ease** — "Setup in 90 seconds" / "One click." +- **Authority** — credentials, named proof, pedigree. "Built by the team that shipped X." +- **Story / specificity together** — "Last Tuesday at 2:47 PM, Sarah was about to lose a $40K deal..." — narrative anchors abstract claims. + +`references/levers.md` has deeper writeups of each with example copy and when to use (and avoid) them. + +--- + +## Format-specific rules + +See `references/format_specs.md` for the character counts, structural conventions, and known pitfalls for every format this skill handles: +- Email subject lines and preview text +- Facebook / Instagram / TikTok ad copy +- Google Ads (Responsive Search Ads) +- Landing page hero sections +- Sales page sections (headline, subhead, bullet lists, CTA stacks) +- Product descriptions (Shopify / Amazon style) +- CTAs (button text) +- Social captions (LinkedIn, X, Instagram) +- Cold outreach openers + +Load that reference file when the user's format has specific constraints or conventions you need to hit. + +--- + +## Tone calibration + +Ask or infer the tone from context. Default defaults: +- **B2B SaaS** — confident, specific, light humor OK, avoid corporate jargon +- **DTC consumer** — warm, benefit-forward, sensory language +- **High-ticket service / coaching** — authority + transformation, less "buy now" energy +- **Newsletter / creator** — conversational, first-person, direct-to-reader +- **Legacy brand / enterprise** — measured, credibility cues, fewer exclamation marks + +If the user has a brand voice guide or existing copy to mimic, ask for it or ask them to paste samples. Copy that doesn't match existing brand voice is worse than no copy. + +--- + +## What to avoid + +- **Weasel words.** "Innovative," "leading," "world-class," "revolutionary." These say nothing. Cut them. +- **Feature lists masquerading as benefits.** "100GB storage" isn't a benefit. "Never delete a file to free up space again" is. +- **Headline that explains everything.** The headline earns the next line, not the sale. Don't cram. +- **CTAs that describe the click.** "Click here" / "Submit" describe the mouse action. Good CTAs describe the outcome: "Get my plan" / "Start my free trial" / "Send me the guide." +- **Copy that apologizes for itself.** "We're just a small team trying to..." — write like you're worth the reader's time, or the reader won't read. +- **Three variations that are the same variation.** If you wrote the same angle three times with different words, pick one and start over on the other two. + +--- + +## Philosophy + +Direct-response copy isn't about tricking people. It's about matching a real offer to a real audience in clear language that moves them to act. The frameworks, levers, and formats are just tools to make that match faster and more reliably. + +The best copy sounds like one human talking to another about something that matters. If your three variations don't sound like something a person would actually say out loud, go again. + \ No newline at end of file diff --git a/skills/copywriter/references/format_specs.md b/skills/copywriter/references/format_specs.md new file mode 100755 index 0000000..c210212 --- /dev/null +++ b/skills/copywriter/references/format_specs.md @@ -0,0 +1,78 @@ +# Format Specs — Constraints and Conventions by Format + +Each format has rules. Break them at your own risk — they exist because of how the format is consumed. + +## Email subject lines +- **Length:** 40–60 characters. Under 40 is OK for very sharp lines. Over 60 starts getting truncated on mobile. +- **Preview text:** separately craft the preview (aka "preheader") — first ~85 chars shown after the subject. Don't let it auto-populate from "Having trouble viewing?" +- **What works:** curiosity gaps, specificity, lowercase + casual, personal-seeming (lowercase, no brand, first-name energy). +- **What fails:** all caps, multiple exclamation points, generic ("Our newsletter"), spam triggers ("FREE!!!", "ACT NOW", $$$ symbols). +- **Three styles to test:** curiosity ("how i doubled replies last week"), specificity ("+23% reply rate — here's the tweak"), personal ("quick question for you"). + +## Facebook / Instagram ad copy +- **Primary text:** the text above the image. First 125 characters show before "See more" — front-load the hook. +- **Headline:** 25–40 characters under the image. This is the single most-seen copy. +- **Description:** 30 characters under the headline on some placements. +- **What works:** pattern interrupt first line, specific numbers, emojis sparingly for scannability, CTA explicit. +- **What fails:** long setups, generic hooks, text that reads like a brochure. + +## TikTok / Reels caption +- **Length:** short. 100–300 characters max. +- **First line:** the "stop scrolling" line. Treat it like a subject line. +- **Hashtags:** 3–5, mix of broad and niche. +- **CTA:** soft. "Follow for more" / "Comment X if..." + +## Google Ads — Responsive Search Ads +- **Headlines:** up to 15, each max 30 characters. Google rotates the best combinations. +- **Descriptions:** up to 4, each max 90 characters. +- **What works:** keyword in headline, distinct headlines (don't write 15 variants of the same line), descriptions that expand the pitch. +- **What fails:** repetition, punctuation that wastes characters, vague claims. + +## Landing page hero section +- **Headline:** 6–12 words. The single most-read line on the site. +- **Subhead:** 15–25 words. Answers "so what?" or adds the specific benefit. +- **CTA button:** 2–4 words. Action-oriented, outcome-focused. +- **Supporting proof element:** social proof ("Used by X"), logos, or a specific number right below the CTA. +- **What works:** lead with the benefit, not the brand. Specificity beats cleverness. Subhead earns the scroll. + +## Sales page sections +- **Section headlines (H2):** 6–15 words. Each one should make sense read alone — imagine the reader scrolling fast and reading only the headlines. +- **Bullet lists:** start every bullet with a benefit or outcome, not a feature. Parallel structure across bullets. +- **CTA stacks:** every 500–800 words, offer a CTA. Readers buy at different scroll depths; don't make the buyer scroll all the way back up. +- **Close:** the last section should handle the final objection — cost, time, risk — and offer a guarantee if you have one. + +## Product descriptions (Shopify / Amazon) +- **Shopify:** 150–300 words. First 2 sentences show above the fold on mobile. Lead with the primary benefit. Use short paragraphs and 3–5 bullets for scanners. +- **Amazon:** 5 bullets + description. Bullets get keyword weight in Amazon search. Lead each bullet with a benefit in ALL CAPS (2–3 words), then explain in normal case. +- **What fails:** feature-dump. "100% cotton, 14oz, machine washable" sells nothing. "Heavyweight 14oz cotton that gets softer with every wash" sells. + +## CTAs (button text) +- **Length:** 2–5 words. +- **Rule:** describe the outcome, not the click. +- **Good:** `Get my plan`, `Start free trial`, `Send me the guide`, `Book my demo`, `See pricing`, `Download the template`. +- **Bad:** `Click here`, `Submit`, `Send`, `Learn more` (sometimes OK, but usually lazy). + +## Social captions +- **LinkedIn:** 1,300 characters max before "see more." Hook in first 2 lines. Skip lines between paragraphs. End with a question or reflection. +- **X / Twitter:** 280 chars. Lead with the claim. If threading, first tweet should stand alone. +- **Instagram caption:** 2,200 chars allowed but most top posts use 100–500. Hook first line, whitespace between paragraphs. + +## Cold outreach openers +- **Length:** 2–4 sentences for the first email, body included. +- **Don't:** "Hope you're doing well." / "Quick question." / "I came across your company..." +- **Do:** lead with something specific to the recipient (a recent post, launch, announcement) + a single-sentence reason for reaching out + a single-sentence ask. +- **Best opener formula:** specific observation about them → one-line credential about you → specific ask with a clear out. + +## Character-count quick reference + +| Format | Max | +|---|---| +| Google Ads headline | 30 | +| X / Twitter post | 280 | +| Email subject line | 60 (target) | +| Meta description | 160 | +| Facebook ad primary text (before "more") | 125 | +| LinkedIn post (before "see more") | ~210 | +| Instagram caption (before "more") | ~125 | + +Include character counts in your variations when writing for these formats. The constraint is the art. diff --git a/skills/copywriter/references/frameworks.md b/skills/copywriter/references/frameworks.md new file mode 100755 index 0000000..890b8a6 --- /dev/null +++ b/skills/copywriter/references/frameworks.md @@ -0,0 +1,168 @@ +# Copywriting Frameworks — Full Playbook + +The four frameworks in SKILL.md cover most jobs. This file goes deeper and adds specialized frameworks for when the standard four don't fit. + +## Table of contents +- AIDA — the workhorse +- PAS — the knife twist +- FAB — the translator +- BAB — the transformation arc +- 4Us — headline discipline +- PASTOR — long-form sales pages +- StoryBrand — brand narrative structure +- How to choose between them + +--- + +## AIDA — Attention, Interest, Desire, Action + +The oldest framework in the book and still the most used. Every piece of direct-response copy you've ever read follows some version of AIDA. + +**Structure:** +- **Attention** — a hook that makes the reader stop +- **Interest** — specifics that earn continued reading +- **Desire** — paint the picture of having the thing +- **Action** — one clear next step + +**When it shines:** short formats where the reader's attention is a resource you can lose in a second. Email subject lines + opening. Cold outreach. Display ads. Landing page heroes. + +**Example (email):** +- Attention: `Subject: The email trick that booked me 3 demos last Tuesday` +- Interest: `Here's what happened. I had 47 cold emails out and exactly 0 replies...` +- Desire: `By 4pm Tuesday I had three calls on the calendar. Here's what changed.` +- Action: `The full breakdown is in this week's issue — grab it here → [link]` + +**Failure mode:** starting strong, fading in the middle. The Interest and Desire sections are where most AIDA copy dies. Keep them concrete and specific. + +--- + +## PAS — Problem, Agitate, Solution + +The best framework for problem-aware audiences. The reader already knows they have the pain; you name it, you twist the knife, then you offer the release. + +**Structure:** +- **Problem** — name the pain in the reader's language +- **Agitate** — make them feel it. Not fear-mongering — just honest specificity about what it costs them +- **Solution** — your offer as the clean exit + +**When it shines:** sales pages for pain-driven purchases (back pain, sleep issues, burned out in their job, failing to hit revenue targets). Any audience that already knows the problem exists. + +**Example (SaaS for agency billing):** +- Problem: `You're leaving money on the table every month.` +- Agitate: `Untracked time. Unbilled revisions. Scope creep that nobody logged. Multiply that by 6 people and 12 months and the number you're staring at is between $40K and $120K in unbilled work — every year.` +- Solution: `TimeStitch tracks it automatically so you bill every hour you earn.` + +**Failure mode:** over-agitating. If the reader feels manipulated, you've lost them. Stop twisting the knife the moment the reader is nodding. Don't belabor. + +--- + +## FAB — Features, Advantages, Benefits + +The essential framework for feature-heavy products. Forces you to translate specs into what they do (advantages) into what the reader gains (benefits). + +**Structure:** +- **Feature** — the literal spec +- **Advantage** — what that spec enables +- **Benefit** — what the reader gains (usually emotional, time, money, status) + +**When it shines:** SaaS, physical products, technical tools where you have to actually explain what the thing is. + +**Example (mattress):** +- Feature: `3-layer gel memory foam, 14" total thickness` +- Advantage: `Contours to your spine without the heat trap of traditional memory foam` +- Benefit: `Wake up without back pain. Sleep cool enough to stop kicking the covers off at 3am.` + +**Writing shortcut:** the "so what?" test. After every feature, ask "so what?" and keep answering until you land on something the reader actually cares about. That's the benefit. + +**Failure mode:** stopping at advantages. "Contours to your spine" isn't a benefit — it's an advantage. "Wake up without back pain" is a benefit. The job isn't done until you reach the emotional payoff. + +--- + +## BAB — Before, After, Bridge + +The transformation framework. Reader is buying a future state; you show the gap and position the offer as the bridge. + +**Structure:** +- **Before** — where they are now, specifically +- **After** — where they want to be +- **Bridge** — your offer as the path + +**When it shines:** coaching, courses, health and fitness, career pivots, anything where the purchase is really a purchase of a different identity or outcome. + +**Example (career coaching):** +- Before: `You're five years into a career you fell into. The salary is fine. The work is draining. Every Sunday night, the same knot in your stomach.` +- After: `Six months from now: a role you chose on purpose. Higher cap, higher interest, Sundays that feel like Sundays.` +- Bridge: `The Pivot Program is how 400+ mid-career professionals got there. 12 weeks, a real plan, real accountability, and a cohort of people doing the same hard thing.` + +**Failure mode:** generic Before and After. "You're unhappy" → "You're happy" is not copy. Specificity is what makes BAB work. The reader has to see themselves in the Before and want to be the person in the After. + +--- + +## 4Us — Useful, Urgent, Unique, Ultra-specific + +Not a full framework, more of a headline filter. Every headline should hit at least three of the four. + +- **Useful** — is there a clear benefit to the reader? +- **Urgent** — is there a reason to read/act now? +- **Unique** — does it sound different from every other headline in the category? +- **Ultra-specific** — are there concrete numbers, names, times? + +Example that hits all four: `How a $2.3M boutique agency cut time-to-cash from 45 days to 9 — in 6 weeks` +- Useful (cash flow improvement), Urgent (implied by the timeline), Unique (most "agency cash flow" headlines don't lead with specifics), Ultra-specific (4 concrete numbers). + +Use the 4Us as a check on any headline before shipping it. + +--- + +## PASTOR — for long-form sales pages + +Ray Edwards' framework for long-form sales letters. Basically an expanded PAS. + +- **Person / Problem** — who this is for and the problem they have +- **Amplify** — cost of the problem +- **Story / Solution** — your story of solving it +- **Transformation / Testimony** — what changed + proof +- **Offer** — the actual pitch +- **Response** — clear call to action + +Use this for sales pages over 1,500 words, webinar pitches, or any long-form piece where you need to walk the reader through a complete arc. + +--- + +## StoryBrand — brand narrative structure + +Donald Miller's framework. Frames the customer as the hero, the brand as the guide. + +- A **Character** (the customer) +- has a **Problem** +- and meets a **Guide** (the brand) +- who gives them a **Plan** +- and calls them to **Action** +- that ends in **Success** +- and helps them avoid **Failure** + +Best for brand-level messaging (homepage, about page, brand video scripts) — not for single headlines or ads. Too scaffolded for that. + +--- + +## How to choose between them + +Ask these questions in order: + +1. **Does the audience already know they have this problem?** + - Yes → PAS or BAB + - No → AIDA (you have to create awareness) + +2. **Is the buyer purchasing a transformation or a tool?** + - Transformation (identity, outcome) → BAB + - Tool (features they'll use) → FAB + +3. **How much space do you have?** + - Short (headline, subject line, ad) → AIDA or the 4Us as a filter + - Medium (landing page section) → any of the main four + - Long (sales page, webinar pitch) → PASTOR + +4. **Is this for brand messaging across the whole site?** + - → StoryBrand at the top layer, with the main four used within sections + +If you're unsure, default to AIDA. It almost always works. diff --git a/skills/copywriter/references/levers.md b/skills/copywriter/references/levers.md new file mode 100755 index 0000000..b10496e --- /dev/null +++ b/skills/copywriter/references/levers.md @@ -0,0 +1,175 @@ +# Psychological Levers — The Copywriter's Toolkit + +These are the moves direct-response copy makes to earn the click, the signup, the sale. Each lever is a hypothesis about the reader's mind. Mix and match across your three variations. + +## Loss aversion + +Fear of losing is roughly 2x stronger than desire to gain (Kahneman). Copy that frames the offer as *preventing loss* often outperforms copy framing the same offer as *achieving gain*. + +**Before:** "Double your reply rate with better cold emails." +**After (loss aversion):** "Stop losing deals to cold emails that never get opened." + +**When to use:** audiences that are already aware of the pain. Doesn't work well on cold audiences who don't feel the problem yet — you'd be loss-framing a loss they don't feel. + +**When to avoid:** luxury / status purchases. No one buys a Rolex because they're afraid of losing something. + +--- + +## Social proof + +Other people did this and it worked. Our brains read that as "this is safe." + +**Formats:** +- Numbers: `Used by 47,000 agencies` +- Named customers: `From the team at Stripe, Notion, and Vercel` +- Reviews: `4.9 stars from 2,000+ reviews` +- Media: `Featured in TechCrunch, The Verge, and Forbes` +- Testimonials: specific quotes from named customers with their role + +**When to use:** always, if you have it. Even a small number (`200 founders`) beats no number. + +**When to avoid:** when the numbers would undermine — if you have 12 customers, don't lead with that. Lead with who they are. + +--- + +## Specificity + +Specific numbers and details read as true. Round numbers and vague descriptors read as fake. + +**Before:** "Significant improvement in conversion" +**After:** "23% lift in checkout completion in week 1" + +**Before:** "Our customers save time" +**After:** "The average agency saves 4.2 hours per week per project manager" + +**When to use:** everywhere you can back it up. If you don't have the number, get the number before you write the copy. + +**When to avoid:** when you don't have the data. Making up a specific number is worse than using a vague claim — if the specific is ever challenged and proven fake, trust collapses. + +--- + +## Curiosity gap + +Imply information the reader wants and doesn't have. The mind hates an open loop. + +**Before:** "Tips for better sleep" +**After:** "The 4-minute evening routine that doubled my deep sleep" + +**Before:** "Improve your Facebook ads" +**After:** "Why our $37 ad outperformed our $12,000 ad" + +**When to use:** email subject lines, ad headlines, blog titles — anywhere the goal is the next click. + +**When to avoid:** CTAs and post-click copy. Once the reader has clicked, deliver on the gap immediately or you've broken the trust. + +--- + +## Status / identity + +Copy that says *this is for people like you* or *people like you use this* taps identity signaling, which is often more powerful than feature selling. + +**Examples:** +- `For founders who actually ship.` +- `Designed for operators, not tourists.` +- `The tool professional writers use when they have to ship fast.` + +**When to use:** when your audience has a clear identity they'd like to reinforce. Works especially well in B2B (job title pride) and premium consumer (taste signaling). + +**When to avoid:** when you're trying to reach a broad audience. Identity copy is inherently exclusive — that's the point — and alienates anyone outside the identity. + +--- + +## Urgency / scarcity + +Real urgency is a cheat code. Fake urgency is a trust-destroyer. + +**Real urgency:** +- Real deadline: `Early-bird pricing ends Friday` +- Limited inventory: `12 spots left in the October cohort` +- Real calendar: `Booking calls for Q2 until April 15` + +**Fake urgency (avoid):** +- `Only a few left!` (with no number) +- `Limited time only!` (with no deadline) +- Countdown timers that reset when you reload + +**When to use:** any time you have real urgency and aren't using it. + +**When to avoid:** whenever you're tempted to manufacture it. Readers detect this and it poisons the rest of the copy. + +--- + +## Contrast + +The mind reads in contrast. Before/after. Us/them. Old way/new way. + +**Examples:** +- `Old way: 6 tools stitched together. New way: one platform.` +- `Most agencies track time in three places. We track it in one.` +- `Before: 40-minute weekly reports. After: 4-minute ones.` + +**When to use:** when your offer is a meaningful upgrade from the status quo. Contrast makes the upgrade feel real. + +**When to avoid:** when the contrast is manufactured. If the "old way" is a straw man nobody actually uses, the reader sees through it. + +--- + +## Speed / ease + +Humans systematically underestimate future effort. Copy that promises speed and ease converts because the reader's brain discounts the cost. + +**Examples:** +- `Setup in 90 seconds.` +- `One click to enable.` +- `No credit card required.` +- `Paste your link — we do the rest.` + +**When to use:** when speed is genuinely a feature. SaaS onboarding, form fills, any low-commitment action. + +**When to avoid:** high-ticket or transformation purchases. "Get a coaching certification in 90 seconds" is a lie the reader will punish you for. + +--- + +## Authority + +Credentials, named proof, pedigree. Reader doesn't have to trust you — they trust the thing that vouches for you. + +**Formats:** +- `Built by the team that shipped Stripe Atlas.` +- `Featured in the New York Times.` +- `PhD-level expertise without the PhD-level wait.` +- `Certified by [known credential body].` + +**When to use:** when you have real authority to name. Works especially well when the reader doesn't know you but trusts whoever vouches for you. + +**When to avoid:** when you don't have it. Never invent authority — easiest way to destroy credibility. + +--- + +## Story + specificity together + +The strongest copy combines narrative with concrete detail. The mind latches onto a story and the details anchor it as real. + +**Weak:** "Sarah got better results with our platform." +**Strong:** "Last Tuesday at 2:47 PM, Sarah was about to lose a $40,000 deal — the buyer had ghosted her for 9 days. She sent one email using our follow-up template. 11 minutes later, the deal was back on." + +**When to use:** email body copy, sales page storytelling sections, case study lead-ins. Anywhere you have space for 2–3 sentences of narrative. + +**When to avoid:** headlines and subject lines (no room), CTAs (no room). Tight formats need compression, not narrative. + +--- + +## The levers, ranked by reliability + +If you had to bet on which levers land, in order: + +1. **Specificity** — almost always helps. Low downside. +2. **Social proof** — if you have it, use it. +3. **Authority** — same. +4. **Contrast** — reliable in medium-to-long formats. +5. **Loss aversion** — works when the audience feels the pain. +6. **Curiosity gap** — high variance; works great for opens, can hurt trust if abused. +7. **Story + specificity** — expensive to write well; pays off if you nail it. +8. **Status / identity** — powerful but narrow audience. +9. **Urgency / scarcity** — cheat code when real, poison when fake. +10. **Speed / ease** — useful modifier, rarely the main lever. diff --git a/skills/disclosure-analyzer/SKILL.md b/skills/disclosure-analyzer/SKILL.md new file mode 100644 index 0000000..279dab3 --- /dev/null +++ b/skills/disclosure-analyzer/SKILL.md @@ -0,0 +1,332 @@ +--- +name: disclosure-analyzer +description: "Disclosure & Inspection Report Analyzer for real estate transactions. Use this skill ANY time the user mentions: disclosures, inspection report, TDS, SPQ, AVID, seller disclosures, pest report, termite report, foundation inspection, roof inspection, sewer lateral, home inspection, property condition, inspection findings, disclosure review, inspection analysis, cross-reference disclosures, buyer review, contingency review, seller credit, repair request, credit request, negotiate repairs, inspection objection, or anything related to analyzing property condition documents in a real estate transaction. Also trigger when the user uploads PDF inspection reports or disclosure forms, asks about issues found in inspections, wants to know what the seller disclosed vs. what the inspector found, needs a summary of property condition findings for their buyer, or wants help drafting a seller credit request based on inspection findings. Supports PDF report and email-ready HTML output plus seller credit request drafting." +--- + +# Disclosure & Inspection Report Analyzer + +You are a real estate disclosure and inspection report analyst. Your job is to take seller disclosure forms and inspection reports from a real estate transaction, extract the important findings, cross-reference what the seller said against what the inspectors found, and produce a clear, organized report that a buyer (and their agent) can use to understand the property's condition. + +**Before generating any report, read the reference file:** +- `references/cost-estimates.md` — Common repair cost ranges for Northern California / Bay Area market + +--- + +## How This Works + +The user (a real estate agent) will upload some combination of: +- **Seller disclosures** — forms the seller fills out describing what they know about the property (TDS, SPQ, AVID, and other standard CAR forms) +- **Inspection reports** — professional reports from inspectors (general home inspection, pest/termite, roof, foundation, sewer lateral, chimney, pool, etc.) + +Your job is to read all of them, pull out the meaningful findings, and produce an organized analysis. The two key things you're doing: + +1. **Extracting and categorizing findings** from every inspection report by severity +2. **Cross-referencing disclosures against inspections** to flag where the seller's statements don't match what the inspectors found + +--- + +## Step 1: Intake — Collect the Documents + +Ask the user what documents they have. Common combinations include: + +- Seller disclosures (TDS, SPQ, AVID, other CAR forms) +- General home inspection +- Pest / termite (Section 1 and Section 2 findings) +- Roof inspection +- Foundation inspection +- Sewer lateral inspection (camera scope) +- Chimney inspection +- Pool/spa inspection +- Any other specialty reports + +Also ask: + +- **Property address** (for the report header) +- **Include cost estimates?** Some buyers want ballpark cost ranges for repairs, others don't. Ask every time. + +If the user has already provided documents and info, skip ahead — don't re-ask for things you already have. + +--- + +## Step 2: Extract and Analyze + +### Reading Seller Disclosures + +Seller disclosures are forms where the seller checks boxes and writes notes about what they know about the property. Focus on: + +- Anything marked "Yes" with an explanation — these are things the seller is explicitly flagging +- Written notes in the margins or explanation sections — sellers sometimes bury important info here +- Items the seller marks as "Unknown" or leaves blank — note these, especially if they relate to something an inspector flagged + +Keep in mind that disclosures reflect the seller's *knowledge*, not the property's actual condition. A 90-year-old seller who has never been in her crawl space genuinely may not know about foundation issues that an inspector finds. That doesn't make her dishonest — it means she's answering based on what she knows. + +When discrepancies come up, state them as simple facts — no ominous language, no implications. Just: "The seller indicated no knowledge of foundation issues on the TDS. The foundation inspection report identified X." That's it. Let the facts speak. The goal is to inform, not alarm. We're giving people the cold hard truth without drama. + +### Reading Inspection Reports + +For each inspection report, extract: + +- **Critical findings** — things that affect safety, structural integrity, or could cause major damage (active leaks, foundation movement, electrical hazards, structural deficiencies, active pest damage to structural members, sewer line failures, etc.) +- **Moderate findings** — things that need attention and cost real money but aren't emergencies (aging roof with 3-5 years of life left, outdated electrical panel that still functions, minor pest damage, HVAC nearing end of life, etc.) +- **Minor findings** — maintenance items and cosmetic stuff (small sidewalk cracks, weathered caulking, minor grading issues, slow drains, etc.) + +Use your judgment on severity. A crack in a sidewalk is minor. A crack in a foundation stem wall is critical. Context matters — if a report says "evidence of past moisture intrusion, area is currently dry, no active damage" that's moderate (monitor it), not critical. + +### Practical Scope Callouts + +When damage to one area is extensive enough that fixing it essentially means remodeling that area, say so. This is important context for the buyer — they need to understand the real scope of what they're getting into. + +For example: if a bathroom has severe dry rot in the subfloor, joists, and walls, fixing it means tearing out the flooring, replacing structural members, re-doing plumbing connections, and putting everything back together. By the time you're doing all that, you're essentially remodeling the bathroom. Say that plainly: "The extent of damage in this area means that repair work would effectively constitute a full bathroom remodel." + +Same logic applies to other areas — if the kitchen has extensive pest damage plus outdated electrical plus plumbing issues all in the same walls, note that addressing everything together is realistically a renovation, not a series of small fixes. This helps the buyer think about costs and timeline realistically. + +### Cross-Referencing + +Go through the disclosure forms item by item and compare against inspection findings. You're looking for: + +- **Discrepancies** — seller said "no" or "unknown" to something, but the inspection found evidence of it. Be fair about why this might happen (see the note above about seller knowledge). +- **Confirmed issues** — seller disclosed something AND the inspection confirmed it. Good — this means the seller was upfront. +- **Inspection-only findings** — things the inspector found that the disclosures don't address at all. These aren't necessarily discrepancies — inspectors look at things sellers might not think to disclose. + +--- + +## Step 3: Produce the Report + +### Report Structure + +Organize by severity, with the most important stuff first. The buyer should be able to read the first page and understand the big picture. + +**Report sections:** + +#### Header +- Property address +- Date of analysis +- List of documents reviewed (with dates of each report) + +#### Executive Summary +- 3-5 sentences covering the overall condition picture +- Total count of critical, moderate, and minor findings +- Any major discrepancy between disclosures and inspections +- One-line bottom line: is this property in generally good shape with some items to address, or are there significant concerns? + +#### Critical Findings +- Each finding gets its own entry with: + - **What was found** — plain language description + - **Source** — which report, what page/section if possible + - **Disclosure cross-reference** — what did the seller say about this? If nothing, note that + - **Cost estimate** (if the user requested cost estimates) — a realistic range. See the cost estimates reference for guidance. When an inspection report quotes a repair cost, note that the actual total cost may be higher because inspectors often quote only their scope of work. For example, a termite company quotes for treating and removing damaged wood, but doesn't include the cost of the flooring contractor to put the floor back, or the plumber to reconnect pipes they had to move. Account for the full scope when estimating. +- If there are no critical findings, say so — that's good news + +#### Moderate Findings +- Same format as critical, but these are the "should address within 1-2 years" items +- Group by system/area if there are many (e.g., multiple plumbing items together) + +#### Minor / Maintenance Items +- These can be more compact — a simple list with brief descriptions is fine +- No cost estimates needed for minor items unless the user specifically asks + +#### Disclosure vs. Inspection Comparison +- A clear table or section showing notable discrepancies +- For each discrepancy: + - What the seller stated + - What the inspection found + - A fair note explaining possible reasons for the difference +- Also note areas where disclosures and inspections align — it's good to show the seller was forthcoming where they were + +#### Documents Reviewed +- List every document that was analyzed, with its date and inspector/company name + +--- + +## Output Formats + +Ask the user whether they want a **PDF** or an **email-ready HTML**, or both. + +### PDF Report +- Clean, professional layout using ReportLab +- Install: `pip install reportlab --break-system-packages` +- Neutral styling — no personal agent branding. Use a clean color scheme (dark header, readable body, subtle section dividers) +- Clear typography and good use of whitespace +- Section headers with visual distinction +- Tables for the disclosure comparison section +- Page numbers + +### Email-Ready HTML +- Self-contained HTML with all inline styles (no external CSS or JS) +- Table-based layout for email client compatibility (Gmail, Outlook, Apple Mail) +- 600px max-width +- System font stack +- Same content as the PDF, just formatted for email +- Can be copy-pasted into an email client or sent via API + +Both formats should contain the same level of detail. If the report needs to be thorough, it needs to be thorough regardless of format. + +--- + +## Publishing via Composio (canonical pattern) + +> **Read first:** [`shared-references/publishing-via-composio.md`](../shared-references/publishing-via-composio.md) — single source of truth for ALL skills. + +After generating the disclosure-analysis HTML output, publish via Composio to `Graehamwatts/online-content` so the agent gets a permanent hosted URL. + +**Account:** `github_spar-devata` +**Owner:** `Graehamwatts` +**Repo:** `online-content` +**Branch:** `main` +**Path pattern:** `disclosures/Disclosure_[address].html` +**Hosted URL pattern:** `https://graehamwatts.github.io/online-content/disclosures/Disclosure_[address].html` + +**Tool to use:** `GITHUB_COMMIT_MULTIPLE_FILES` (atomic commit, retry-safe). + +```python +result, error = run_composio_tool( + tool_slug='GITHUB_COMMIT_MULTIPLE_FILES', + arguments={ + 'owner': 'Graehamwatts', + 'repo': 'online-content', + 'branch': 'main', + 'message': 'descriptive commit message', + 'upserts': [{'path': 'disclosures/Disclosure_[address].html', 'content': html_content, 'encoding': 'utf-8'}] + }, + account='github_spar-devata' +) +``` + +**HARD RULES:** +- Do NOT use the legacy GitHub Contents API with PAT or `javascript_tool` chunked uploads (replaced 2026-05-03). +- Do NOT use GitHub Desktop or `git push` from the agent sandbox. +- Run the brand-integrity check before push (see shared doc — blocks DRE# 01 leaks). +- After commit, give the user BOTH the hosted URL and the local `computer://` link. + +See `shared-references/publishing-via-composio.md` for full details, common pitfalls, and verification flow. + +## Cost Estimates + +When the user opts in to cost estimates, provide realistic ballpark ranges. The key principles: + +- **Account for the full scope of work.** Inspectors and specialty contractors often quote only their piece. A termite company quotes for pest treatment and removing damaged material. They don't quote for the general contractor to rebuild, the plumber to reconnect, or the flooring to be replaced. Your estimate should reflect what the buyer will actually spend to resolve the issue end-to-end. +- **Use ranges, not single numbers.** There's always variability — "$2,000–$4,000" is more honest than "$3,000." +- **When you don't know, say so.** Some items genuinely need a specialist quote. "Recommend getting a quote from a licensed contractor" is a perfectly valid response. +- **Read the cost estimates reference** (`references/cost-estimates.md`) for common repair cost ranges calibrated to Northern California pricing. + +--- + +## Seller Credit Request Drafting + +This is an optional feature the user can activate by saying something like "help me draft a credit request" or "what should we ask the seller for." When triggered, you shift from pure analysis mode into negotiation support mode. + +### The Key Principle: Visible vs. Non-Obvious + +When a buyer makes an offer on a property, they're pricing in what they can see. If there's an obviously unpermitted addition or a visibly rough rear structure, the buyer saw that when they toured the property and wrote their offer accordingly. The seller (and their agent) will push back on credit requests for those items: "You knew about that when you made your offer." + +The strongest credit requests are for things the buyer **could not have reasonably known** before inspections: + +**Strong credit request items (non-obvious):** +- Asbestos in ductwork or behind walls — you can't see that on a tour +- Electrical issues behind walls (aluminum wiring, improper splices, missing grounds) +- Plumbing defects (sewer lateral condition, hidden leaks, galvanized pipe corrosion inside walls) +- Pest/termite damage hidden in crawl spaces, subfloor, inside walls +- Foundation issues not visible from the living space +- Roof defects that only a roofer on the roof would find (underlayment condition, flashing failures) +- Environmental hazards (mold behind walls, lead paint under layers) +- HVAC defects (cracked heat exchanger, duct issues in inaccessible areas) + +**Weak credit request items (buyer could see these):** +- Visibly unpermitted additions or structures +- Obvious cosmetic issues (peeling paint, worn carpet, dated fixtures) +- Anything clearly visible during a standard property tour +- Items explicitly called out in the listing or listing photos + +### How to Draft the Credit Request + +When the user asks for this, produce: + +1. **Recommended credit items** — list each non-obvious finding with: + - What was found and where + - Why it wasn't reasonably visible at time of offer + - Estimated cost to address (use cost estimate ranges) + - Which report documented it + +2. **Total recommended credit range** — sum up the cost ranges into a bottom-line ask range + +3. **Items NOT recommended for credit request** — briefly list the items you're leaving out and why (e.g., "The rear structure condition was visually apparent at time of property tour") + +4. **Draft language** — write the actual request language the agent can use. Keep it professional and factual: "During the inspection contingency period, the following conditions were identified that were not apparent during the initial property viewing..." No aggressive tone — just clear documentation of findings and costs. + +If the user has provided the MLS listing or property profile, use it to help determine what was marketed/visible vs. what's newly discovered. If no listing info is available, use reasonable judgment about what a buyer would have seen on a standard tour. + +--- + + + +- **Clear and direct.** No jargon without explanation. If you say "efflorescence on the foundation walls," add "(white mineral deposits that can indicate moisture migration through the concrete)." +- **Fair to the seller.** Never imply dishonesty. Sellers disclose what they know; inspectors find things sellers may not know about. +- **Honest about severity.** Don't downplay critical issues and don't exaggerate minor ones. A buyer needs accurate information to make decisions. +- **Practical.** Frame findings in terms of what it means for the buyer: does this need immediate attention? Can it wait? Is it just something to monitor? + +--- + +## Step 4: Quality Control Verification (MANDATORY) + +**This step is not optional.** Before delivering any report to the user, you MUST run a full verification pass. Mistakes in this type of report are a serious problem — a buyer or their agent could make decisions based on inaccurate information. Every report must be checked before it goes out. + +### The Verification Process + +After generating the report, run a separate verification agent (subagent) that re-reads the original source documents and cross-checks the report for accuracy. If a subagent is not available, perform the verification yourself as a distinct second pass — do NOT just skim what you already wrote. + +### What the Verification Checks + +**1. Severity Accuracy** +- Re-read each finding in the original inspection report. Did the inspector flag it with a safety/repair warning (red flag), or was it an observation only? +- If the inspector did NOT flag something as a safety concern, your report should not escalate it to critical unless there's a clear factual basis (e.g., knob & tube wiring is inherently critical regardless of inspector flagging). +- If the inspector DID flag something as safety/repair, make sure your report reflects that severity — don't accidentally downgrade it. + +**2. Factual Accuracy** +- Every finding in the report must trace back to a specific section/page in the source document. Spot-check at least 5 critical and 5 moderate findings by going back to the source and confirming the report matches what the inspector actually wrote. +- Watch for these common errors: + - **Overstating condition**: Inspector says "general wear, monitor" and the report says "failing" or "needs replacement" + - **Understating condition**: Inspector flags something with a safety warning and the report buries it in moderate + - **Conflating items**: Two separate findings getting merged into one and losing detail, or one finding getting split into two and inflating the count + - **Inventing findings**: A finding appears in the report that isn't actually in any source document. This should never happen. + - **Wrong section/page references**: Source citations that don't match the actual report pages + +**3. Cost Estimate Accuracy** +- Cross-check cost ranges against the `references/cost-estimates.md` file +- Make sure no item has an inflated or deflated range vs. what the reference says +- If the report includes a "full replacement" cost for something that only needs maintenance or repair, flag and fix it. (Example: a roof with maintenance issues should NOT quote a full replacement cost range unless the inspector specifically called for replacement.) +- Verify the summary cost totals add up correctly — don't let rounding errors or removed items create a wrong total + +**4. Disclosure Cross-Reference Accuracy** +- If TDS/SPQ forms were provided, verify that each "seller said X, inspector found Y" statement is accurate to both documents +- If TDS/SPQ were NOT provided, make sure the report clearly states this limitation and doesn't attempt to cross-reference against documents that don't exist +- Make sure the NHD findings (flood zone, seismic, environmental) are reported accurately per the actual NHD document + +**5. Tone Check** +- Scan the report for language that could come across as alarmist, ominous, or accusatory toward the seller +- Remove any editorializing. The report should state facts and let the reader draw conclusions. +- Make sure the "remodel scope callout" language is only used when the damage genuinely warrants it — don't casually throw around "this is essentially a remodel" for moderate repairs + +**6. Completeness Check** +- Compare the report's finding count against the inspector's summary page (most reports have a summary at the front). Are you missing any findings? Are you double-counting any? +- Verify all documents that were uploaded are listed in the "Documents Reviewed" section +- If cost estimates were requested, make sure every critical and moderate finding has one + +### Verification Output + +After the verification pass, fix any errors found. If corrections were made, note them internally (you don't need to tell the user about every correction — just fix them). If a correction changes something significant (e.g., an item moved from critical to moderate, or a cost estimate changed materially), mention it to the user so they know the report was refined. + +**Only deliver the report after verification is complete.** + +--- + +## Common Pitfalls to Avoid + +These are mistakes that have come up in testing. Watch for them: + +1. **Roof condition overstatement.** If the inspector walked the roof, took photos, and found only maintenance items (debris, moss, minor bubbling, flashing paint wear) without issuing safety flags, that is a roof that needs maintenance — not replacement. Do NOT include a "full roof replacement" cost estimate unless the inspector specifically calls for it or the damage clearly warrants it. Look at the actual photos and inspector language, not just the summary line items. + +2. **Counting items that aren't findings.** Inspection reports include informational sections, limitations notes, and general maintenance tips. These are not "findings." Only count actual observations/deficiencies. + +3. **Inflating the critical count.** A finding is critical only if it affects safety, structural integrity, or could cause major damage if left unaddressed. "Cosmetic repairs needed" is never critical. "Sealant recommended" is never critical. Be disciplined about severity classification. + +4. **Missing the forest for the trees.** If 15 individual findings in the same area all point to the same underlying problem (e.g., water intrusion), call out the underlying problem as the main finding and list the individual items as evidence. Don't present them as 15 separate issues when they're really one big one. diff --git a/skills/disclosure-analyzer/generated/.gitkeep b/skills/disclosure-analyzer/generated/.gitkeep new file mode 100755 index 0000000..e69de29 diff --git a/skills/disclosure-analyzer/references/cost-estimates.md b/skills/disclosure-analyzer/references/cost-estimates.md new file mode 100755 index 0000000..e40d260 --- /dev/null +++ b/skills/disclosure-analyzer/references/cost-estimates.md @@ -0,0 +1,113 @@ +# Common Repair Cost Estimates — Northern California / Bay Area + +These are approximate ranges based on typical Bay Area contractor pricing as of 2025-2026. Actual costs vary significantly by property size, access difficulty, extent of damage, and contractor. Always recommend the buyer get actual quotes for critical and moderate items. + +Labor rates in the Bay Area tend to run 30-50% higher than national averages. Factor this in when estimating. + +## Structural / Foundation + +| Item | Range | Notes | +|------|-------|-------| +| Foundation crack repair (epoxy injection, per crack) | $500–$1,500 | Simple cracks only | +| Foundation bolting (seismic retrofit) | $3,000–$7,000 | Depends on linear footage | +| Foundation underpinning (per pier) | $1,500–$3,000 | Typically need 6-12 piers | +| Mudsill replacement (per linear foot) | $40–$80 | Often combined with bolting | +| Post and pier replacement (per post) | $500–$1,200 | Crawl space access matters | +| Structural beam replacement | $2,000–$8,000 | Highly variable by scope | +| Retaining wall repair/replacement | $5,000–$20,000+ | Depends on size and material | + +## Roof + +| Item | Range | Notes | +|------|-------|-------| +| Composition shingle roof (full replacement) | $15,000–$35,000 | Based on typical 1,500-2,500 sqft home | +| Tile roof repair (per area) | $1,000–$5,000 | Replacing broken tiles, reflashing | +| Flat roof section (torch down / TPO) | $3,000–$8,000 | Per 200-400 sqft section | +| Gutter replacement (whole house) | $1,500–$3,500 | | +| Flashing repair | $500–$2,000 | Chimney, vent, valley flashing | + +## Plumbing + +| Item | Range | Notes | +|------|-------|-------| +| Sewer lateral replacement | $8,000–$20,000 | Depends on length, depth, permits | +| Sewer lateral lining (trenchless) | $6,000–$15,000 | Less disruption than replacement | +| Water heater replacement (tank) | $2,000–$4,000 | Installed, with permit | +| Water heater replacement (tankless) | $4,000–$7,000 | Installed, with permit | +| Repipe whole house (copper or PEX) | $8,000–$18,000 | Depends on size, access, wall repair | +| Drain line repair/replacement | $1,500–$5,000 | Per section | +| Hose bib / supply line repair | $200–$600 | | + +## Electrical + +| Item | Range | Notes | +|------|-------|-------| +| Main panel upgrade (100A to 200A) | $3,000–$6,000 | With permit | +| Subpanel addition | $1,500–$3,000 | | +| GFCI outlet installation (each) | $150–$300 | | +| Whole-house rewire | $15,000–$30,000 | Plus drywall repair | +| Knob-and-tube removal (per circuit) | $1,500–$3,500 | | +| Smoke/CO detector installation (hardwired set) | $500–$1,200 | | + +## Pest / Termite + +| Item | Range | Notes | +|------|-------|-------| +| Localized termite treatment (per area) | $500–$1,500 | Chemical or spot treatment | +| Whole-structure fumigation (tenting) | $2,500–$5,000 | Based on home size | +| Section 1 repairs (typical) | $2,000–$8,000 | As quoted by pest company | +| **Add for contractor rebuilding** | **+30-60% of pest quote** | Pest companies don't quote for putting things back together — floors, drywall, trim, plumbing reconnection. Budget this separately. | +| Fungus / dry rot repair (per area) | $1,000–$4,000 | Depends on extent | +| Subterranean termite treatment | $1,500–$3,000 | Soil treatment | + +## HVAC + +| Item | Range | Notes | +|------|-------|-------| +| Furnace replacement | $4,000–$8,000 | Installed, with permit | +| AC unit replacement | $5,000–$10,000 | Installed | +| Full HVAC system (furnace + AC) | $10,000–$20,000 | | +| Ductwork repair/sealing | $1,500–$4,000 | | +| Ductwork replacement | $5,000–$12,000 | | +| Mini-split installation (per zone) | $3,000–$5,000 | | + +## Chimney / Fireplace + +| Item | Range | Notes | +|------|-------|-------| +| Chimney cap installation | $300–$800 | | +| Chimney liner installation | $2,500–$5,000 | Stainless steel liner | +| Chimney rebuild (above roofline) | $5,000–$15,000 | Seismic damage common in Bay Area | +| Fireplace insert (gas) | $3,000–$6,000 | | +| Damper repair/replacement | $300–$800 | | + +## Windows / Doors / Exterior + +| Item | Range | Notes | +|------|-------|-------| +| Window replacement (per window, vinyl) | $600–$1,200 | Installed | +| Window replacement (per window, wood) | $1,000–$2,000 | Installed | +| Sliding glass door replacement | $2,000–$5,000 | | +| Exterior paint (whole house) | $8,000–$18,000 | Depends on size and prep work | +| Siding repair (per section) | $1,000–$4,000 | | +| Deck repair/replacement | $5,000–$20,000 | Depends on size | + +## Miscellaneous + +| Item | Range | Notes | +|------|-------|-------| +| Grading/drainage correction | $2,000–$8,000 | French drain, regrading | +| Sidewalk/driveway repair | $1,000–$5,000 | Depends on area | +| Asbestos testing (per sample) | $25–$75 | Lab fee | +| Asbestos abatement (per area) | $2,000–$10,000+ | Highly regulated, varies widely | +| Lead paint remediation (per room) | $1,000–$3,000 | | +| Mold remediation | $2,000–$10,000+ | Depends on extent | +| Pool replastering | $5,000–$10,000 | | +| Pool equipment replacement | $3,000–$8,000 | Pump, filter, heater | + +## Important Notes + +- These ranges are starting points. Always recommend the buyer get 2-3 actual bids for any significant work. +- Permit costs are generally included in the ranges above, but can add $500-$2,000 for major work. +- If multiple issues overlap in the same area (e.g., pest damage + plumbing + flooring in a bathroom), the total cost may be less than the sum of individual estimates because the contractor is already in the area — but it can also be more if the work is complex. Use judgment. +- Older homes (pre-1950) often have compounding issues that increase costs — access is harder, materials are non-standard, and one repair can reveal another. diff --git a/skills/docx/LICENSE.txt b/skills/docx/LICENSE.txt new file mode 100644 index 0000000..c55ab42 --- /dev/null +++ b/skills/docx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/skills/docx/SKILL.md b/skills/docx/SKILL.md new file mode 100644 index 0000000..2951e55 --- /dev/null +++ b/skills/docx/SKILL.md @@ -0,0 +1,590 @@ +--- +name: docx +description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation." +license: Proprietary. LICENSE.txt has complete terms +--- + +# DOCX creation, editing, and analysis + +## Overview + +A .docx file is a ZIP archive containing XML files. + +## Quick Reference + +| Task | Approach | +|------|----------| +| Read/analyze content | `pandoc` or unpack for raw XML | +| Create new document | Use `docx-js` - see Creating New Documents below | +| Edit existing document | Unpack → edit XML → repack - see Editing Existing Documents below | + +### Converting .doc to .docx + +Legacy `.doc` files must be converted before editing: + +```bash +python scripts/office/soffice.py --headless --convert-to docx document.doc +``` + +### Reading Content + +```bash +# Text extraction with tracked changes +pandoc --track-changes=all document.docx -o output.md + +# Raw XML access +python scripts/office/unpack.py document.docx unpacked/ +``` + +### Converting to Images + +```bash +python scripts/office/soffice.py --headless --convert-to pdf document.docx +pdftoppm -jpeg -r 150 document.pdf page +``` + +### Accepting Tracked Changes + +To produce a clean document with all tracked changes accepted (requires LibreOffice): + +```bash +python scripts/accept_changes.py input.docx output.docx +``` + +--- + +## Creating New Documents + +Generate .docx files with JavaScript, then validate. Install: `npm install -g docx` + +### Setup +```javascript +const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun, + Header, Footer, AlignmentType, PageOrientation, LevelFormat, ExternalHyperlink, + InternalHyperlink, Bookmark, FootnoteReferenceRun, PositionalTab, + PositionalTabAlignment, PositionalTabRelativeTo, PositionalTabLeader, + TabStopType, TabStopPosition, Column, SectionType, + TableOfContents, HeadingLevel, BorderStyle, WidthType, ShadingType, + VerticalAlign, PageNumber, PageBreak } = require('docx'); + +const doc = new Document({ sections: [{ children: [/* content */] }] }); +Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer)); +``` + +### Validation +After creating the file, validate it. If validation fails, unpack, fix the XML, and repack. +```bash +python scripts/office/validate.py doc.docx +``` + +### Page Size + +```javascript +// CRITICAL: docx-js defaults to A4, not US Letter +// Always set page size explicitly for consistent results +sections: [{ + properties: { + page: { + size: { + width: 12240, // 8.5 inches in DXA + height: 15840 // 11 inches in DXA + }, + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } // 1 inch margins + } + }, + children: [/* content */] +}] +``` + +**Common page sizes (DXA units, 1440 DXA = 1 inch):** + +| Paper | Width | Height | Content Width (1" margins) | +|-------|-------|--------|---------------------------| +| US Letter | 12,240 | 15,840 | 9,360 | +| A4 (default) | 11,906 | 16,838 | 9,026 | + +**Landscape orientation:** docx-js swaps width/height internally, so pass portrait dimensions and let it handle the swap: +```javascript +size: { + width: 12240, // Pass SHORT edge as width + height: 15840, // Pass LONG edge as height + orientation: PageOrientation.LANDSCAPE // docx-js swaps them in the XML +}, +// Content width = 15840 - left margin - right margin (uses the long edge) +``` + +### Styles (Override Built-in Headings) + +Use Arial as the default font (universally supported). Keep titles black for readability. + +```javascript +const doc = new Document({ + styles: { + default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt default + paragraphStyles: [ + // IMPORTANT: Use exact IDs to override built-in styles + { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 32, bold: true, font: "Arial" }, + paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 } }, // outlineLevel required for TOC + { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 28, bold: true, font: "Arial" }, + paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 } }, + ] + }, + sections: [{ + children: [ + new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Title")] }), + ] + }] +}); +``` + +### Lists (NEVER use unicode bullets) + +```javascript +// ❌ WRONG - never manually insert bullet characters +new Paragraph({ children: [new TextRun("• Item")] }) // BAD +new Paragraph({ children: [new TextRun("\u2022 Item")] }) // BAD + +// ✅ CORRECT - use numbering config with LevelFormat.BULLET +const doc = new Document({ + numbering: { + config: [ + { reference: "bullets", + levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, + { reference: "numbers", + levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, + ] + }, + sections: [{ + children: [ + new Paragraph({ numbering: { reference: "bullets", level: 0 }, + children: [new TextRun("Bullet item")] }), + new Paragraph({ numbering: { reference: "numbers", level: 0 }, + children: [new TextRun("Numbered item")] }), + ] + }] +}); + +// ⚠️ Each reference creates INDEPENDENT numbering +// Same reference = continues (1,2,3 then 4,5,6) +// Different reference = restarts (1,2,3 then 1,2,3) +``` + +### Tables + +**CRITICAL: Tables need dual widths** - set both `columnWidths` on the table AND `width` on each cell. Without both, tables render incorrectly on some platforms. + +```javascript +// CRITICAL: Always set table width for consistent rendering +// CRITICAL: Use ShadingType.CLEAR (not SOLID) to prevent black backgrounds +const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }; +const borders = { top: border, bottom: border, left: border, right: border }; + +new Table({ + width: { size: 9360, type: WidthType.DXA }, // Always use DXA (percentages break in Google Docs) + columnWidths: [4680, 4680], // Must sum to table width (DXA: 1440 = 1 inch) + rows: [ + new TableRow({ + children: [ + new TableCell({ + borders, + width: { size: 4680, type: WidthType.DXA }, // Also set on each cell + shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, // CLEAR not SOLID + margins: { top: 80, bottom: 80, left: 120, right: 120 }, // Cell padding (internal, not added to width) + children: [new Paragraph({ children: [new TextRun("Cell")] })] + }) + ] + }) + ] +}) +``` + +**Table width calculation:** + +Always use `WidthType.DXA` — `WidthType.PERCENTAGE` breaks in Google Docs. + +```javascript +// Table width = sum of columnWidths = content width +// US Letter with 1" margins: 12240 - 2880 = 9360 DXA +width: { size: 9360, type: WidthType.DXA }, +columnWidths: [7000, 2360] // Must sum to table width +``` + +**Width rules:** +- **Always use `WidthType.DXA`** — never `WidthType.PERCENTAGE` (incompatible with Google Docs) +- Table width must equal the sum of `columnWidths` +- Cell `width` must match corresponding `columnWidth` +- Cell `margins` are internal padding - they reduce content area, not add to cell width +- For full-width tables: use content width (page width minus left and right margins) + +### Images + +```javascript +// CRITICAL: type parameter is REQUIRED +new Paragraph({ + children: [new ImageRun({ + type: "png", // Required: png, jpg, jpeg, gif, bmp, svg + data: fs.readFileSync("image.png"), + transformation: { width: 200, height: 150 }, + altText: { title: "Title", description: "Desc", name: "Name" } // All three required + })] +}) +``` + +### Page Breaks + +```javascript +// CRITICAL: PageBreak must be inside a Paragraph +new Paragraph({ children: [new PageBreak()] }) + +// Or use pageBreakBefore +new Paragraph({ pageBreakBefore: true, children: [new TextRun("New page")] }) +``` + +### Hyperlinks + +```javascript +// External link +new Paragraph({ + children: [new ExternalHyperlink({ + children: [new TextRun({ text: "Click here", style: "Hyperlink" })], + link: "https://example.com", + })] +}) + +// Internal link (bookmark + reference) +// 1. Create bookmark at destination +new Paragraph({ heading: HeadingLevel.HEADING_1, children: [ + new Bookmark({ id: "chapter1", children: [new TextRun("Chapter 1")] }), +]}) +// 2. Link to it +new Paragraph({ children: [new InternalHyperlink({ + children: [new TextRun({ text: "See Chapter 1", style: "Hyperlink" })], + anchor: "chapter1", +})]}) +``` + +### Footnotes + +```javascript +const doc = new Document({ + footnotes: { + 1: { children: [new Paragraph("Source: Annual Report 2024")] }, + 2: { children: [new Paragraph("See appendix for methodology")] }, + }, + sections: [{ + children: [new Paragraph({ + children: [ + new TextRun("Revenue grew 15%"), + new FootnoteReferenceRun(1), + new TextRun(" using adjusted metrics"), + new FootnoteReferenceRun(2), + ], + })] + }] +}); +``` + +### Tab Stops + +```javascript +// Right-align text on same line (e.g., date opposite a title) +new Paragraph({ + children: [ + new TextRun("Company Name"), + new TextRun("\tJanuary 2025"), + ], + tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }], +}) + +// Dot leader (e.g., TOC-style) +new Paragraph({ + children: [ + new TextRun("Introduction"), + new TextRun({ children: [ + new PositionalTab({ + alignment: PositionalTabAlignment.RIGHT, + relativeTo: PositionalTabRelativeTo.MARGIN, + leader: PositionalTabLeader.DOT, + }), + "3", + ]}), + ], +}) +``` + +### Multi-Column Layouts + +```javascript +// Equal-width columns +sections: [{ + properties: { + column: { + count: 2, // number of columns + space: 720, // gap between columns in DXA (720 = 0.5 inch) + equalWidth: true, + separate: true, // vertical line between columns + }, + }, + children: [/* content flows naturally across columns */] +}] + +// Custom-width columns (equalWidth must be false) +sections: [{ + properties: { + column: { + equalWidth: false, + children: [ + new Column({ width: 5400, space: 720 }), + new Column({ width: 3240 }), + ], + }, + }, + children: [/* content */] +}] +``` + +Force a column break with a new section using `type: SectionType.NEXT_COLUMN`. + +### Table of Contents + +```javascript +// CRITICAL: Headings must use HeadingLevel ONLY - no custom styles +new TableOfContents("Table of Contents", { hyperlink: true, headingStyleRange: "1-3" }) +``` + +### Headers/Footers + +```javascript +sections: [{ + properties: { + page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } // 1440 = 1 inch + }, + headers: { + default: new Header({ children: [new Paragraph({ children: [new TextRun("Header")] })] }) + }, + footers: { + default: new Footer({ children: [new Paragraph({ + children: [new TextRun("Page "), new TextRun({ children: [PageNumber.CURRENT] })] + })] }) + }, + children: [/* content */] +}] +``` + +### Critical Rules for docx-js + +- **Set page size explicitly** - docx-js defaults to A4; use US Letter (12240 x 15840 DXA) for US documents +- **Landscape: pass portrait dimensions** - docx-js swaps width/height internally; pass short edge as `width`, long edge as `height`, and set `orientation: PageOrientation.LANDSCAPE` +- **Never use `\n`** - use separate Paragraph elements +- **Never use unicode bullets** - use `LevelFormat.BULLET` with numbering config +- **PageBreak must be in Paragraph** - standalone creates invalid XML +- **ImageRun requires `type`** - always specify png/jpg/etc +- **Always set table `width` with DXA** - never use `WidthType.PERCENTAGE` (breaks in Google Docs) +- **Tables need dual widths** - `columnWidths` array AND cell `width`, both must match +- **Table width = sum of columnWidths** - for DXA, ensure they add up exactly +- **Always add cell margins** - use `margins: { top: 80, bottom: 80, left: 120, right: 120 }` for readable padding +- **Use `ShadingType.CLEAR`** - never SOLID for table shading +- **Never use tables as dividers/rules** - cells have minimum height and render as empty boxes (including in headers/footers); use `border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: "2E75B6", space: 1 } }` on a Paragraph instead. For two-column footers, use tab stops (see Tab Stops section), not tables +- **TOC requires HeadingLevel only** - no custom styles on heading paragraphs +- **Override built-in styles** - use exact IDs: "Heading1", "Heading2", etc. +- **Include `outlineLevel`** - required for TOC (0 for H1, 1 for H2, etc.) + +--- + +## Editing Existing Documents + +**Follow all 3 steps in order.** + +### Step 1: Unpack +```bash +python scripts/office/unpack.py document.docx unpacked/ +``` +Extracts XML, pretty-prints, merges adjacent runs, and converts smart quotes to XML entities (`“` etc.) so they survive editing. Use `--merge-runs false` to skip run merging. + +### Step 2: Edit XML + +Edit files in `unpacked/word/`. See XML Reference below for patterns. + +**Use "Claude" as the author** for tracked changes and comments, unless the user explicitly requests use of a different name. + +**Use the Edit tool directly for string replacement. Do not write Python scripts.** Scripts introduce unnecessary complexity. The Edit tool shows exactly what is being replaced. + +**CRITICAL: Use smart quotes for new content.** When adding text with apostrophes or quotes, use XML entities to produce smart quotes: +```xml + +Here’s a quote: “Hello” +``` +| Entity | Character | +|--------|-----------| +| `‘` | ‘ (left single) | +| `’` | ’ (right single / apostrophe) | +| `“` | “ (left double) | +| `”` | ” (right double) | + +**Adding comments:** Use `comment.py` to handle boilerplate across multiple XML files (text must be pre-escaped XML): +```bash +python scripts/comment.py unpacked/ 0 "Comment text with & and ’" +python scripts/comment.py unpacked/ 1 "Reply text" --parent 0 # reply to comment 0 +python scripts/comment.py unpacked/ 0 "Text" --author "Custom Author" # custom author name +``` +Then add markers to document.xml (see Comments in XML Reference). + +### Step 3: Pack +```bash +python scripts/office/pack.py unpacked/ output.docx --original document.docx +``` +Validates with auto-repair, condenses XML, and creates DOCX. Use `--validate false` to skip. + +**Auto-repair will fix:** +- `durableId` >= 0x7FFFFFFF (regenerates valid ID) +- Missing `xml:space="preserve"` on `` with whitespace + +**Auto-repair won't fix:** +- Malformed XML, invalid element nesting, missing relationships, schema violations + +### Common Pitfalls + +- **Replace entire `` elements**: When adding tracked changes, replace the whole `...` block with `......` as siblings. Don't inject tracked change tags inside a run. +- **Preserve `` formatting**: Copy the original run's `` block into your tracked change runs to maintain bold, font size, etc. + +--- + +## XML Reference + +### Schema Compliance + +- **Element order in ``**: ``, ``, ``, ``, ``, `` last +- **Whitespace**: Add `xml:space="preserve"` to `` with leading/trailing spaces +- **RSIDs**: Must be 8-digit hex (e.g., `00AB1234`) + +### Tracked Changes + +**Insertion:** +```xml + + inserted text + +``` + +**Deletion:** +```xml + + deleted text + +``` + +**Inside ``**: Use `` instead of ``, and `` instead of ``. + +**Minimal edits** - only mark what changes: +```xml + +The term is + + 30 + + + 60 + + days. +``` + +**Deleting entire paragraphs/list items** - when removing ALL content from a paragraph, also mark the paragraph mark as deleted so it merges with the next paragraph. Add `` inside ``: +```xml + + + ... + + + + + + Entire paragraph content being deleted... + + +``` +Without the `` in ``, accepting changes leaves an empty paragraph/list item. + +**Rejecting another author's insertion** - nest deletion inside their insertion: +```xml + + + their inserted text + + +``` + +**Restoring another author's deletion** - add insertion after (don't modify their deletion): +```xml + + deleted text + + + deleted text + +``` + +### Comments + +After running `comment.py` (see Step 2), add markers to document.xml. For replies, use `--parent` flag and nest markers inside the parent's. + +**CRITICAL: `` and `` are siblings of ``, never inside ``.** + +```xml + + + + deleted + + more text + + + + + + + text + + + + +``` + +### Images + +1. Add image file to `word/media/` +2. Add relationship to `word/_rels/document.xml.rels`: +```xml + +``` +3. Add content type to `[Content_Types].xml`: +```xml + +``` +4. Reference in document.xml: +```xml + + + + + + + + + + + + +``` + +--- + +## Dependencies + +- **pandoc**: Text extraction +- **docx**: `npm install -g docx` (new documents) +- **LibreOffice**: PDF conversion (auto-configured for sandboxed environments via `scripts/office/soffice.py`) +- **Poppler**: `pdftoppm` for images diff --git a/skills/docx/scripts/__init__.py b/skills/docx/scripts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/docx/scripts/__init__.py @@ -0,0 +1 @@ + diff --git a/skills/docx/scripts/accept_changes.py b/skills/docx/scripts/accept_changes.py new file mode 100644 index 0000000..8e36316 --- /dev/null +++ b/skills/docx/scripts/accept_changes.py @@ -0,0 +1,135 @@ +"""Accept all tracked changes in a DOCX file using LibreOffice. + +Requires LibreOffice (soffice) to be installed. +""" + +import argparse +import logging +import shutil +import subprocess +from pathlib import Path + +from office.soffice import get_soffice_env + +logger = logging.getLogger(__name__) + +LIBREOFFICE_PROFILE = "/tmp/libreoffice_docx_profile" +MACRO_DIR = f"{LIBREOFFICE_PROFILE}/user/basic/Standard" + +ACCEPT_CHANGES_MACRO = """ + + + Sub AcceptAllTrackedChanges() + Dim document As Object + Dim dispatcher As Object + + document = ThisComponent.CurrentController.Frame + dispatcher = createUnoService("com.sun.star.frame.DispatchHelper") + + dispatcher.executeDispatch(document, ".uno:AcceptAllTrackedChanges", "", 0, Array()) + ThisComponent.store() + ThisComponent.close(True) + End Sub +""" + + +def accept_changes( + input_file: str, + output_file: str, +) -> tuple[None, str]: + input_path = Path(input_file) + output_path = Path(output_file) + + if not input_path.exists(): + return None, f"Error: Input file not found: {input_file}" + + if not input_path.suffix.lower() == ".docx": + return None, f"Error: Input file is not a DOCX file: {input_file}" + + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(input_path, output_path) + except Exception as e: + return None, f"Error: Failed to copy input file to output location: {e}" + + if not _setup_libreoffice_macro(): + return None, "Error: Failed to setup LibreOffice macro" + + cmd = [ + "soffice", + "--headless", + f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}", + "--norestore", + "vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges?language=Basic&location=application", + str(output_path.absolute()), + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + check=False, + env=get_soffice_env(), + ) + except subprocess.TimeoutExpired: + return ( + None, + f"Successfully accepted all tracked changes: {input_file} -> {output_file}", + ) + + if result.returncode != 0: + return None, f"Error: LibreOffice failed: {result.stderr}" + + return ( + None, + f"Successfully accepted all tracked changes: {input_file} -> {output_file}", + ) + + +def _setup_libreoffice_macro() -> bool: + macro_dir = Path(MACRO_DIR) + macro_file = macro_dir / "Module1.xba" + + if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text(): + return True + + if not macro_dir.exists(): + subprocess.run( + [ + "soffice", + "--headless", + f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}", + "--terminate_after_init", + ], + capture_output=True, + timeout=10, + check=False, + env=get_soffice_env(), + ) + macro_dir.mkdir(parents=True, exist_ok=True) + + try: + macro_file.write_text(ACCEPT_CHANGES_MACRO) + return True + except Exception as e: + logger.warning(f"Failed to setup LibreOffice macro: {e}") + return False + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Accept all tracked changes in a DOCX file" + ) + parser.add_argument("input_file", help="Input DOCX file with tracked changes") + parser.add_argument( + "output_file", help="Output DOCX file (clean, no tracked changes)" + ) + args = parser.parse_args() + + _, message = accept_changes(args.input_file, args.output_file) + print(message) + + if "Error" in message: + raise SystemExit(1) diff --git a/skills/docx/scripts/comment.py b/skills/docx/scripts/comment.py new file mode 100644 index 0000000..36e1c93 --- /dev/null +++ b/skills/docx/scripts/comment.py @@ -0,0 +1,318 @@ +"""Add comments to DOCX documents. + +Usage: + python comment.py unpacked/ 0 "Comment text" + python comment.py unpacked/ 1 "Reply text" --parent 0 + +Text should be pre-escaped XML (e.g., & for &, ’ for smart quotes). + +After running, add markers to document.xml: + + ... commented content ... + + +""" + +import argparse +import random +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path + +import defusedxml.minidom + +TEMPLATE_DIR = Path(__file__).parent / "templates" +NS = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "w14": "http://schemas.microsoft.com/office/word/2010/wordml", + "w15": "http://schemas.microsoft.com/office/word/2012/wordml", + "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", + "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex", +} + +COMMENT_XML = """\ + + + + + + + + + + + + + {text} + + +""" + +COMMENT_MARKER_TEMPLATE = """ +Add to document.xml (markers must be direct children of w:p, never inside w:r): + + ... + + """ + +REPLY_MARKER_TEMPLATE = """ +Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r): + + ... + + + """ + + +def _generate_hex_id() -> str: + return f"{random.randint(0, 0x7FFFFFFE):08X}" + + +SMART_QUOTE_ENTITIES = { + "\u201c": "“", + "\u201d": "”", + "\u2018": "‘", + "\u2019": "’", +} + + +def _encode_smart_quotes(text: str) -> str: + for char, entity in SMART_QUOTE_ENTITIES.items(): + text = text.replace(char, entity) + return text + + +def _append_xml(xml_path: Path, root_tag: str, content: str) -> None: + dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8")) + root = dom.getElementsByTagName(root_tag)[0] + ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items()) + wrapper_dom = defusedxml.minidom.parseString(f"{content}") + for child in wrapper_dom.documentElement.childNodes: + if child.nodeType == child.ELEMENT_NODE: + root.appendChild(dom.importNode(child, True)) + output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8")) + xml_path.write_text(output, encoding="utf-8") + + +def _find_para_id(comments_path: Path, comment_id: int) -> str | None: + dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8")) + for c in dom.getElementsByTagName("w:comment"): + if c.getAttribute("w:id") == str(comment_id): + for p in c.getElementsByTagName("w:p"): + if pid := p.getAttribute("w14:paraId"): + return pid + return None + + +def _get_next_rid(rels_path: Path) -> int: + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + max_rid = 0 + for rel in dom.getElementsByTagName("Relationship"): + rid = rel.getAttribute("Id") + if rid and rid.startswith("rId"): + try: + max_rid = max(max_rid, int(rid[3:])) + except ValueError: + pass + return max_rid + 1 + + +def _has_relationship(rels_path: Path, target: str) -> bool: + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + for rel in dom.getElementsByTagName("Relationship"): + if rel.getAttribute("Target") == target: + return True + return False + + +def _has_content_type(ct_path: Path, part_name: str) -> bool: + dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) + for override in dom.getElementsByTagName("Override"): + if override.getAttribute("PartName") == part_name: + return True + return False + + +def _ensure_comment_relationships(unpacked_dir: Path) -> None: + rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels" + if not rels_path.exists(): + return + + if _has_relationship(rels_path, "comments.xml"): + return + + dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8")) + root = dom.documentElement + next_rid = _get_next_rid(rels_path) + + rels = [ + ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", + "comments.xml", + ), + ( + "http://schemas.microsoft.com/office/2011/relationships/commentsExtended", + "commentsExtended.xml", + ), + ( + "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", + "commentsIds.xml", + ), + ( + "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible", + "commentsExtensible.xml", + ), + ] + + for rel_type, target in rels: + rel = dom.createElement("Relationship") + rel.setAttribute("Id", f"rId{next_rid}") + rel.setAttribute("Type", rel_type) + rel.setAttribute("Target", target) + root.appendChild(rel) + next_rid += 1 + + rels_path.write_bytes(dom.toxml(encoding="UTF-8")) + + +def _ensure_comment_content_types(unpacked_dir: Path) -> None: + ct_path = unpacked_dir / "[Content_Types].xml" + if not ct_path.exists(): + return + + if _has_content_type(ct_path, "/word/comments.xml"): + return + + dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8")) + root = dom.documentElement + + overrides = [ + ( + "/word/comments.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", + ), + ( + "/word/commentsExtended.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml", + ), + ( + "/word/commentsIds.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml", + ), + ( + "/word/commentsExtensible.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml", + ), + ] + + for part_name, content_type in overrides: + override = dom.createElement("Override") + override.setAttribute("PartName", part_name) + override.setAttribute("ContentType", content_type) + root.appendChild(override) + + ct_path.write_bytes(dom.toxml(encoding="UTF-8")) + + +def add_comment( + unpacked_dir: str, + comment_id: int, + text: str, + author: str = "Claude", + initials: str = "C", + parent_id: int | None = None, +) -> tuple[str, str]: + word = Path(unpacked_dir) / "word" + if not word.exists(): + return "", f"Error: {word} not found" + + para_id, durable_id = _generate_hex_id(), _generate_hex_id() + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + comments = word / "comments.xml" + first_comment = not comments.exists() + if first_comment: + shutil.copy(TEMPLATE_DIR / "comments.xml", comments) + _ensure_comment_relationships(Path(unpacked_dir)) + _ensure_comment_content_types(Path(unpacked_dir)) + _append_xml( + comments, + "w:comments", + COMMENT_XML.format( + id=comment_id, + author=author, + date=ts, + initials=initials, + para_id=para_id, + text=text, + ), + ) + + ext = word / "commentsExtended.xml" + if not ext.exists(): + shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext) + if parent_id is not None: + parent_para = _find_para_id(comments, parent_id) + if not parent_para: + return "", f"Error: Parent comment {parent_id} not found" + _append_xml( + ext, + "w15:commentsEx", + f'', + ) + else: + _append_xml( + ext, + "w15:commentsEx", + f'', + ) + + ids = word / "commentsIds.xml" + if not ids.exists(): + shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids) + _append_xml( + ids, + "w16cid:commentsIds", + f'', + ) + + extensible = word / "commentsExtensible.xml" + if not extensible.exists(): + shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible) + _append_xml( + extensible, + "w16cex:commentsExtensible", + f'', + ) + + action = "reply" if parent_id is not None else "comment" + return para_id, f"Added {action} {comment_id} (para_id={para_id})" + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Add comments to DOCX documents") + p.add_argument("unpacked_dir", help="Unpacked DOCX directory") + p.add_argument("comment_id", type=int, help="Comment ID (must be unique)") + p.add_argument("text", help="Comment text") + p.add_argument("--author", default="Claude", help="Author name") + p.add_argument("--initials", default="C", help="Author initials") + p.add_argument("--parent", type=int, help="Parent comment ID (for replies)") + args = p.parse_args() + + para_id, msg = add_comment( + args.unpacked_dir, + args.comment_id, + args.text, + args.author, + args.initials, + args.parent, + ) + print(msg) + if "Error" in msg: + sys.exit(1) + cid = args.comment_id + if args.parent is not None: + print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid)) + else: + print(COMMENT_MARKER_TEMPLATE.format(cid=cid)) diff --git a/skills/docx/scripts/office/helpers/__init__.py b/skills/docx/scripts/office/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/docx/scripts/office/helpers/merge_runs.py b/skills/docx/scripts/office/helpers/merge_runs.py new file mode 100644 index 0000000..ad7c25e --- /dev/null +++ b/skills/docx/scripts/office/helpers/merge_runs.py @@ -0,0 +1,199 @@ +"""Merge adjacent runs with identical formatting in DOCX. + +Merges adjacent elements that have identical properties. +Works on runs in paragraphs and inside tracked changes (, ). + +Also: +- Removes rsid attributes from runs (revision metadata that doesn't affect rendering) +- Removes proofErr elements (spell/grammar markers that block merging) +""" + +from pathlib import Path + +import defusedxml.minidom + + +def merge_runs(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + _remove_elements(root, "proofErr") + _strip_run_rsid_attrs(root) + + containers = {run.parentNode for run in _find_elements(root, "r")} + + merge_count = 0 + for container in containers: + merge_count += _merge_runs_in(container) + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Merged {merge_count} runs" + + except Exception as e: + return 0, f"Error: {e}" + + + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def _get_child(parent, tag: str): + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + return child + return None + + +def _get_children(parent, tag: str) -> list: + results = [] + for child in parent.childNodes: + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(child) + return results + + +def _is_adjacent(elem1, elem2) -> bool: + node = elem1.nextSibling + while node: + if node == elem2: + return True + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + return False + + + + +def _remove_elements(root, tag: str): + for elem in _find_elements(root, tag): + if elem.parentNode: + elem.parentNode.removeChild(elem) + + +def _strip_run_rsid_attrs(root): + for run in _find_elements(root, "r"): + for attr in list(run.attributes.values()): + if "rsid" in attr.name.lower(): + run.removeAttribute(attr.name) + + + + +def _merge_runs_in(container) -> int: + merge_count = 0 + run = _first_child_run(container) + + while run: + while True: + next_elem = _next_element_sibling(run) + if next_elem and _is_run(next_elem) and _can_merge(run, next_elem): + _merge_run_content(run, next_elem) + container.removeChild(next_elem) + merge_count += 1 + else: + break + + _consolidate_text(run) + run = _next_sibling_run(run) + + return merge_count + + +def _first_child_run(container): + for child in container.childNodes: + if child.nodeType == child.ELEMENT_NODE and _is_run(child): + return child + return None + + +def _next_element_sibling(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + return sibling + sibling = sibling.nextSibling + return None + + +def _next_sibling_run(node): + sibling = node.nextSibling + while sibling: + if sibling.nodeType == sibling.ELEMENT_NODE: + if _is_run(sibling): + return sibling + sibling = sibling.nextSibling + return None + + +def _is_run(node) -> bool: + name = node.localName or node.tagName + return name == "r" or name.endswith(":r") + + +def _can_merge(run1, run2) -> bool: + rpr1 = _get_child(run1, "rPr") + rpr2 = _get_child(run2, "rPr") + + if (rpr1 is None) != (rpr2 is None): + return False + if rpr1 is None: + return True + return rpr1.toxml() == rpr2.toxml() + + +def _merge_run_content(target, source): + for child in list(source.childNodes): + if child.nodeType == child.ELEMENT_NODE: + name = child.localName or child.tagName + if name != "rPr" and not name.endswith(":rPr"): + target.appendChild(child) + + +def _consolidate_text(run): + t_elements = _get_children(run, "t") + + for i in range(len(t_elements) - 1, 0, -1): + curr, prev = t_elements[i], t_elements[i - 1] + + if _is_adjacent(prev, curr): + prev_text = prev.firstChild.data if prev.firstChild else "" + curr_text = curr.firstChild.data if curr.firstChild else "" + merged = prev_text + curr_text + + if prev.firstChild: + prev.firstChild.data = merged + else: + prev.appendChild(run.ownerDocument.createTextNode(merged)) + + if merged.startswith(" ") or merged.endswith(" "): + prev.setAttribute("xml:space", "preserve") + elif prev.hasAttribute("xml:space"): + prev.removeAttribute("xml:space") + + run.removeChild(curr) diff --git a/skills/docx/scripts/office/helpers/simplify_redlines.py b/skills/docx/scripts/office/helpers/simplify_redlines.py new file mode 100644 index 0000000..db963bb --- /dev/null +++ b/skills/docx/scripts/office/helpers/simplify_redlines.py @@ -0,0 +1,197 @@ +"""Simplify tracked changes by merging adjacent w:ins or w:del elements. + +Merges adjacent elements from the same author into a single element. +Same for elements. This makes heavily-redlined documents easier to +work with by reducing the number of tracked change wrappers. + +Rules: +- Only merges w:ins with w:ins, w:del with w:del (same element type) +- Only merges if same author (ignores timestamp differences) +- Only merges if truly adjacent (only whitespace between them) +""" + +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +import defusedxml.minidom + +WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +def simplify_redlines(input_dir: str) -> tuple[int, str]: + doc_xml = Path(input_dir) / "word" / "document.xml" + + if not doc_xml.exists(): + return 0, f"Error: {doc_xml} not found" + + try: + dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8")) + root = dom.documentElement + + merge_count = 0 + + containers = _find_elements(root, "p") + _find_elements(root, "tc") + + for container in containers: + merge_count += _merge_tracked_changes_in(container, "ins") + merge_count += _merge_tracked_changes_in(container, "del") + + doc_xml.write_bytes(dom.toxml(encoding="UTF-8")) + return merge_count, f"Simplified {merge_count} tracked changes" + + except Exception as e: + return 0, f"Error: {e}" + + +def _merge_tracked_changes_in(container, tag: str) -> int: + merge_count = 0 + + tracked = [ + child + for child in container.childNodes + if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag) + ] + + if len(tracked) < 2: + return 0 + + i = 0 + while i < len(tracked) - 1: + curr = tracked[i] + next_elem = tracked[i + 1] + + if _can_merge_tracked(curr, next_elem): + _merge_tracked_content(curr, next_elem) + container.removeChild(next_elem) + tracked.pop(i + 1) + merge_count += 1 + else: + i += 1 + + return merge_count + + +def _is_element(node, tag: str) -> bool: + name = node.localName or node.tagName + return name == tag or name.endswith(f":{tag}") + + +def _get_author(elem) -> str: + author = elem.getAttribute("w:author") + if not author: + for attr in elem.attributes.values(): + if attr.localName == "author" or attr.name.endswith(":author"): + return attr.value + return author + + +def _can_merge_tracked(elem1, elem2) -> bool: + if _get_author(elem1) != _get_author(elem2): + return False + + node = elem1.nextSibling + while node and node != elem2: + if node.nodeType == node.ELEMENT_NODE: + return False + if node.nodeType == node.TEXT_NODE and node.data.strip(): + return False + node = node.nextSibling + + return True + + +def _merge_tracked_content(target, source): + while source.firstChild: + child = source.firstChild + source.removeChild(child) + target.appendChild(child) + + +def _find_elements(root, tag: str) -> list: + results = [] + + def traverse(node): + if node.nodeType == node.ELEMENT_NODE: + name = node.localName or node.tagName + if name == tag or name.endswith(f":{tag}"): + results.append(node) + for child in node.childNodes: + traverse(child) + + traverse(root) + return results + + +def get_tracked_change_authors(doc_xml_path: Path) -> dict[str, int]: + if not doc_xml_path.exists(): + return {} + + try: + tree = ET.parse(doc_xml_path) + root = tree.getroot() + except ET.ParseError: + return {} + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + + return authors + + +def _get_authors_from_docx(docx_path: Path) -> dict[str, int]: + try: + with zipfile.ZipFile(docx_path, "r") as zf: + if "word/document.xml" not in zf.namelist(): + return {} + with zf.open("word/document.xml") as f: + tree = ET.parse(f) + root = tree.getroot() + + namespaces = {"w": WORD_NS} + author_attr = f"{{{WORD_NS}}}author" + + authors: dict[str, int] = {} + for tag in ["ins", "del"]: + for elem in root.findall(f".//w:{tag}", namespaces): + author = elem.get(author_attr) + if author: + authors[author] = authors.get(author, 0) + 1 + return authors + except (zipfile.BadZipFile, ET.ParseError): + return {} + + +def infer_author(modified_dir: Path, original_docx: Path, default: str = "Claude") -> str: + modified_xml = modified_dir / "word" / "document.xml" + modified_authors = get_tracked_change_authors(modified_xml) + + if not modified_authors: + return default + + original_authors = _get_authors_from_docx(original_docx) + + new_changes: dict[str, int] = {} + for author, count in modified_authors.items(): + original_count = original_authors.get(author, 0) + diff = count - original_count + if diff > 0: + new_changes[author] = diff + + if not new_changes: + return default + + if len(new_changes) == 1: + return next(iter(new_changes)) + + raise ValueError( + f"Multiple authors added new changes: {new_changes}. " + "Cannot infer which author to validate." + ) diff --git a/skills/docx/scripts/office/pack.py b/skills/docx/scripts/office/pack.py new file mode 100644 index 0000000..db29ed8 --- /dev/null +++ b/skills/docx/scripts/office/pack.py @@ -0,0 +1,159 @@ +"""Pack a directory into a DOCX, PPTX, or XLSX file. + +Validates with auto-repair, condenses XML formatting, and creates the Office file. + +Usage: + python pack.py [--original ] [--validate true|false] + +Examples: + python pack.py unpacked/ output.docx --original input.docx + python pack.py unpacked/ output.pptx --validate false +""" + +import argparse +import sys +import shutil +import tempfile +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + +def pack( + input_directory: str, + output_file: str, + original_file: str | None = None, + validate: bool = True, + infer_author_func=None, +) -> tuple[None, str]: + input_dir = Path(input_directory) + output_path = Path(output_file) + suffix = output_path.suffix.lower() + + if not input_dir.is_dir(): + return None, f"Error: {input_dir} is not a directory" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {output_file} must be a .docx, .pptx, or .xlsx file" + + if validate and original_file: + original_path = Path(original_file) + if original_path.exists(): + success, output = _run_validation( + input_dir, original_path, suffix, infer_author_func + ) + if output: + print(output) + if not success: + return None, f"Error: Validation failed for {input_dir}" + + with tempfile.TemporaryDirectory() as temp_dir: + temp_content_dir = Path(temp_dir) / "content" + shutil.copytree(input_dir, temp_content_dir) + + for pattern in ["*.xml", "*.rels"]: + for xml_file in temp_content_dir.rglob(pattern): + _condense_xml(xml_file) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + for f in temp_content_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(temp_content_dir)) + + return None, f"Successfully packed {input_dir} to {output_file}" + + +def _run_validation( + unpacked_dir: Path, + original_file: Path, + suffix: str, + infer_author_func=None, +) -> tuple[bool, str | None]: + output_lines = [] + validators = [] + + if suffix == ".docx": + author = "Claude" + if infer_author_func: + try: + author = infer_author_func(unpacked_dir, original_file) + except ValueError as e: + print(f"Warning: {e} Using default author 'Claude'.", file=sys.stderr) + + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file), + RedliningValidator(unpacked_dir, original_file, author=author), + ] + elif suffix == ".pptx": + validators = [PPTXSchemaValidator(unpacked_dir, original_file)] + + if not validators: + return True, None + + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + output_lines.append(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + output_lines.append("All validations PASSED!") + + return success, "\n".join(output_lines) if output_lines else None + + +def _condense_xml(xml_file: Path) -> None: + try: + with open(xml_file, encoding="utf-8") as f: + dom = defusedxml.minidom.parse(f) + + for element in dom.getElementsByTagName("*"): + if element.tagName.endswith(":t"): + continue + + for child in list(element.childNodes): + if ( + child.nodeType == child.TEXT_NODE + and child.nodeValue + and child.nodeValue.strip() == "" + ) or child.nodeType == child.COMMENT_NODE: + element.removeChild(child) + + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + except Exception as e: + print(f"ERROR: Failed to parse {xml_file.name}: {e}", file=sys.stderr) + raise + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Pack a directory into a DOCX, PPTX, or XLSX file" + ) + parser.add_argument("input_directory", help="Unpacked Office document directory") + parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") + parser.add_argument( + "--original", + help="Original file for validation comparison", + ) + parser.add_argument( + "--validate", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Run validation with auto-repair (default: true)", + ) + args = parser.parse_args() + + _, message = pack( + args.input_directory, + args.output_file, + original_file=args.original, + validate=args.validate, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd new file mode 100644 index 0000000..bc325f9 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd new file mode 100644 index 0000000..afa4f46 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd new file mode 100644 index 0000000..40e4b12 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd new file mode 100644 index 0000000..687eea8 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd new file mode 100644 index 0000000..94644b3 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd new file mode 100644 index 0000000..1dbf051 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd new file mode 100644 index 0000000..f1af17d --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd new file mode 100644 index 0000000..5c00a6f --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd new file mode 100644 index 0000000..25564eb --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd new file mode 100644 index 0000000..c20f3bf --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd new file mode 100644 index 0000000..ac60252 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd new file mode 100644 index 0000000..52deec7 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd new file mode 100644 index 0000000..2bddce2 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd new file mode 100644 index 0000000..8a8c18b --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd new file mode 100644 index 0000000..5c42706 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd new file mode 100644 index 0000000..853c341 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 0000000..da835ee --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd new file mode 100644 index 0000000..4f37d30 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd new file mode 100644 index 0000000..9e86f1b --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd new file mode 100644 index 0000000..237dd65 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd new file mode 100644 index 0000000..eeb4ef8 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd new file mode 100644 index 0000000..ca2575c --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd new file mode 100644 index 0000000..dd079e6 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd new file mode 100644 index 0000000..3dd6cf6 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd new file mode 100644 index 0000000..f1041e3 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd new file mode 100644 index 0000000..9c5b7a6 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd new file mode 100644 index 0000000..fbd8876 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd new file mode 100644 index 0000000..e4c5160 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd new file mode 100644 index 0000000..888c0fc --- /dev/null +++ b/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd new file mode 100644 index 0000000..7378226 --- /dev/null +++ b/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd new file mode 100644 index 0000000..762dcbe --- /dev/null +++ b/skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/mce/mc.xsd b/skills/docx/scripts/office/schemas/mce/mc.xsd new file mode 100644 index 0000000..ef72545 --- /dev/null +++ b/skills/docx/scripts/office/schemas/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd b/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd new file mode 100644 index 0000000..f65f777 --- /dev/null +++ b/skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd b/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd new file mode 100644 index 0000000..6b00755 --- /dev/null +++ b/skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd b/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd new file mode 100644 index 0000000..f321d33 --- /dev/null +++ b/skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd new file mode 100644 index 0000000..364c6a9 --- /dev/null +++ b/skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd new file mode 100644 index 0000000..fed9d15 --- /dev/null +++ b/skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd new file mode 100644 index 0000000..680cf15 --- /dev/null +++ b/skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd new file mode 100644 index 0000000..89ada90 --- /dev/null +++ b/skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/skills/docx/scripts/office/soffice.py b/skills/docx/scripts/office/soffice.py new file mode 100644 index 0000000..c7f7e32 --- /dev/null +++ b/skills/docx/scripts/office/soffice.py @@ -0,0 +1,183 @@ +""" +Helper for running LibreOffice (soffice) in environments where AF_UNIX +sockets may be blocked (e.g., sandboxed VMs). Detects the restriction +at runtime and applies an LD_PRELOAD shim if needed. + +Usage: + from office.soffice import run_soffice, get_soffice_env + + # Option 1 – run soffice directly + result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"]) + + # Option 2 – get env dict for your own subprocess calls + env = get_soffice_env() + subprocess.run(["soffice", ...], env=env) +""" + +import os +import socket +import subprocess +import tempfile +from pathlib import Path + + +def get_soffice_env() -> dict: + env = os.environ.copy() + env["SAL_USE_VCLPLUGIN"] = "svp" + + if _needs_shim(): + shim = _ensure_shim() + env["LD_PRELOAD"] = str(shim) + + return env + + +def run_soffice(args: list[str], **kwargs) -> subprocess.CompletedProcess: + env = get_soffice_env() + return subprocess.run(["soffice"] + args, env=env, **kwargs) + + + +_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so" + + +def _needs_shim() -> bool: + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.close() + return False + except OSError: + return True + + +def _ensure_shim() -> Path: + if _SHIM_SO.exists(): + return _SHIM_SO + + src = Path(tempfile.gettempdir()) / "lo_socket_shim.c" + src.write_text(_SHIM_SOURCE) + subprocess.run( + ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"], + check=True, + capture_output=True, + ) + src.unlink() + return _SHIM_SO + + + +_SHIM_SOURCE = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int (*real_socket)(int, int, int); +static int (*real_socketpair)(int, int, int, int[2]); +static int (*real_listen)(int, int); +static int (*real_accept)(int, struct sockaddr *, socklen_t *); +static int (*real_close)(int); +static int (*real_read)(int, void *, size_t); + +/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */ +static int is_shimmed[1024]; +static int peer_of[1024]; +static int wake_r[1024]; /* accept() blocks reading this */ +static int wake_w[1024]; /* close() writes to this */ +static int listener_fd = -1; /* FD that received listen() */ + +__attribute__((constructor)) +static void init(void) { + real_socket = dlsym(RTLD_NEXT, "socket"); + real_socketpair = dlsym(RTLD_NEXT, "socketpair"); + real_listen = dlsym(RTLD_NEXT, "listen"); + real_accept = dlsym(RTLD_NEXT, "accept"); + real_close = dlsym(RTLD_NEXT, "close"); + real_read = dlsym(RTLD_NEXT, "read"); + for (int i = 0; i < 1024; i++) { + peer_of[i] = -1; + wake_r[i] = -1; + wake_w[i] = -1; + } +} + +/* ---- socket ---------------------------------------------------------- */ +int socket(int domain, int type, int protocol) { + if (domain == AF_UNIX) { + int fd = real_socket(domain, type, protocol); + if (fd >= 0) return fd; + /* socket(AF_UNIX) blocked – fall back to socketpair(). */ + int sv[2]; + if (real_socketpair(domain, type, protocol, sv) == 0) { + if (sv[0] >= 0 && sv[0] < 1024) { + is_shimmed[sv[0]] = 1; + peer_of[sv[0]] = sv[1]; + int wp[2]; + if (pipe(wp) == 0) { + wake_r[sv[0]] = wp[0]; + wake_w[sv[0]] = wp[1]; + } + } + return sv[0]; + } + errno = EPERM; + return -1; + } + return real_socket(domain, type, protocol); +} + +/* ---- listen ---------------------------------------------------------- */ +int listen(int sockfd, int backlog) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + listener_fd = sockfd; + return 0; + } + return real_listen(sockfd, backlog); +} + +/* ---- accept ---------------------------------------------------------- */ +int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) { + if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) { + /* Block until close() writes to the wake pipe. */ + if (wake_r[sockfd] >= 0) { + char buf; + real_read(wake_r[sockfd], &buf, 1); + } + errno = ECONNABORTED; + return -1; + } + return real_accept(sockfd, addr, addrlen); +} + +/* ---- close ----------------------------------------------------------- */ +int close(int fd) { + if (fd >= 0 && fd < 1024 && is_shimmed[fd]) { + int was_listener = (fd == listener_fd); + is_shimmed[fd] = 0; + + if (wake_w[fd] >= 0) { /* unblock accept() */ + char c = 0; + write(wake_w[fd], &c, 1); + real_close(wake_w[fd]); + wake_w[fd] = -1; + } + if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; } + if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; } + + if (was_listener) + _exit(0); /* conversion done – exit */ + } + return real_close(fd); +} +""" + + + +if __name__ == "__main__": + import sys + result = run_soffice(sys.argv[1:]) + sys.exit(result.returncode) diff --git a/skills/docx/scripts/office/unpack.py b/skills/docx/scripts/office/unpack.py new file mode 100644 index 0000000..0015253 --- /dev/null +++ b/skills/docx/scripts/office/unpack.py @@ -0,0 +1,132 @@ +"""Unpack Office files (DOCX, PPTX, XLSX) for editing. + +Extracts the ZIP archive, pretty-prints XML files, and optionally: +- Merges adjacent runs with identical formatting (DOCX only) +- Simplifies adjacent tracked changes from same author (DOCX only) + +Usage: + python unpack.py [options] + +Examples: + python unpack.py document.docx unpacked/ + python unpack.py presentation.pptx unpacked/ + python unpack.py document.docx unpacked/ --merge-runs false +""" + +import argparse +import sys +import zipfile +from pathlib import Path + +import defusedxml.minidom + +from helpers.merge_runs import merge_runs as do_merge_runs +from helpers.simplify_redlines import simplify_redlines as do_simplify_redlines + +SMART_QUOTE_REPLACEMENTS = { + "\u201c": "“", + "\u201d": "”", + "\u2018": "‘", + "\u2019": "’", +} + + +def unpack( + input_file: str, + output_directory: str, + merge_runs: bool = True, + simplify_redlines: bool = True, +) -> tuple[None, str]: + input_path = Path(input_file) + output_path = Path(output_directory) + suffix = input_path.suffix.lower() + + if not input_path.exists(): + return None, f"Error: {input_file} does not exist" + + if suffix not in {".docx", ".pptx", ".xlsx"}: + return None, f"Error: {input_file} must be a .docx, .pptx, or .xlsx file" + + try: + output_path.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(input_path, "r") as zf: + zf.extractall(output_path) + + xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels")) + for xml_file in xml_files: + _pretty_print_xml(xml_file) + + message = f"Unpacked {input_file} ({len(xml_files)} XML files)" + + if suffix == ".docx": + if simplify_redlines: + simplify_count, _ = do_simplify_redlines(str(output_path)) + message += f", simplified {simplify_count} tracked changes" + + if merge_runs: + merge_count, _ = do_merge_runs(str(output_path)) + message += f", merged {merge_count} runs" + + for xml_file in xml_files: + _escape_smart_quotes(xml_file) + + return None, message + + except zipfile.BadZipFile: + return None, f"Error: {input_file} is not a valid Office file" + except Exception as e: + return None, f"Error unpacking: {e}" + + +def _pretty_print_xml(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="utf-8")) + except Exception: + pass + + +def _escape_smart_quotes(xml_file: Path) -> None: + try: + content = xml_file.read_text(encoding="utf-8") + for char, entity in SMART_QUOTE_REPLACEMENTS.items(): + content = content.replace(char, entity) + xml_file.write_text(content, encoding="utf-8") + except Exception: + pass + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Unpack an Office file (DOCX, PPTX, XLSX) for editing" + ) + parser.add_argument("input_file", help="Office file to unpack") + parser.add_argument("output_directory", help="Output directory") + parser.add_argument( + "--merge-runs", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent runs with identical formatting (DOCX only, default: true)", + ) + parser.add_argument( + "--simplify-redlines", + type=lambda x: x.lower() == "true", + default=True, + metavar="true|false", + help="Merge adjacent tracked changes from same author (DOCX only, default: true)", + ) + args = parser.parse_args() + + _, message = unpack( + args.input_file, + args.output_directory, + merge_runs=args.merge_runs, + simplify_redlines=args.simplify_redlines, + ) + print(message) + + if "Error" in message: + sys.exit(1) diff --git a/skills/docx/scripts/office/validate.py b/skills/docx/scripts/office/validate.py new file mode 100644 index 0000000..03b01f6 --- /dev/null +++ b/skills/docx/scripts/office/validate.py @@ -0,0 +1,111 @@ +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py [--original ] [--auto-repair] [--author NAME] + +The first argument can be either: +- An unpacked directory containing the Office document XML files +- A packed Office file (.docx/.pptx/.xlsx) which will be unpacked to a temp directory + +Auto-repair fixes: +- paraId/durableId values that exceed OOXML limits +- Missing xml:space="preserve" on w:t elements with whitespace +""" + +import argparse +import sys +import tempfile +import zipfile +from pathlib import Path + +from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "path", + help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx)", + ) + parser.add_argument( + "--original", + required=False, + default=None, + help="Path to original file (.docx/.pptx/.xlsx). If omitted, all XSD errors are reported and redlining validation is skipped.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + parser.add_argument( + "--auto-repair", + action="store_true", + help="Automatically repair common issues (hex IDs, whitespace preservation)", + ) + parser.add_argument( + "--author", + default="Claude", + help="Author name for redlining validation (default: Claude)", + ) + args = parser.parse_args() + + path = Path(args.path) + assert path.exists(), f"Error: {path} does not exist" + + original_file = None + if args.original: + original_file = Path(args.original) + assert original_file.is_file(), f"Error: {original_file} is not a file" + assert original_file.suffix.lower() in [".docx", ".pptx", ".xlsx"], ( + f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" + ) + + file_extension = (original_file or path).suffix.lower() + assert file_extension in [".docx", ".pptx", ".xlsx"], ( + f"Error: Cannot determine file type from {path}. Use --original or provide a .docx/.pptx/.xlsx file." + ) + + if path.is_file() and path.suffix.lower() in [".docx", ".pptx", ".xlsx"]: + temp_dir = tempfile.mkdtemp() + with zipfile.ZipFile(path, "r") as zf: + zf.extractall(temp_dir) + unpacked_dir = Path(temp_dir) + else: + assert path.is_dir(), f"Error: {path} is not a directory or Office file" + unpacked_dir = path + + match file_extension: + case ".docx": + validators = [ + DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + if original_file: + validators.append( + RedliningValidator(unpacked_dir, original_file, verbose=args.verbose, author=args.author) + ) + case ".pptx": + validators = [ + PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose), + ] + case _: + print(f"Error: Validation not supported for file type {file_extension}") + sys.exit(1) + + if args.auto_repair: + total_repairs = sum(v.repair() for v in validators) + if total_repairs: + print(f"Auto-repaired {total_repairs} issue(s)") + + success = all(v.validate() for v in validators) + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/skills/docx/scripts/office/validators/__init__.py b/skills/docx/scripts/office/validators/__init__.py new file mode 100644 index 0000000..db092ec --- /dev/null +++ b/skills/docx/scripts/office/validators/__init__.py @@ -0,0 +1,15 @@ +""" +Validation modules for Word document processing. +""" + +from .base import BaseSchemaValidator +from .docx import DOCXSchemaValidator +from .pptx import PPTXSchemaValidator +from .redlining import RedliningValidator + +__all__ = [ + "BaseSchemaValidator", + "DOCXSchemaValidator", + "PPTXSchemaValidator", + "RedliningValidator", +] diff --git a/skills/docx/scripts/office/validators/base.py b/skills/docx/scripts/office/validators/base.py new file mode 100644 index 0000000..875de69 --- /dev/null +++ b/skills/docx/scripts/office/validators/base.py @@ -0,0 +1,851 @@ +""" +Base validator with common validation logic for document files. +""" + +import re +from pathlib import Path + +import defusedxml.minidom +import lxml.etree + + +class BaseSchemaValidator: + + IGNORED_VALIDATION_ERRORS = [ + "hyphenationZone", + "purl.org/dc/terms", + ] + + UNIQUE_ID_REQUIREMENTS = { + "comment": ("id", "file"), + "commentrangestart": ("id", "file"), + "commentrangeend": ("id", "file"), + "bookmarkstart": ("id", "file"), + "bookmarkend": ("id", "file"), + "sldid": ("id", "file"), + "sldmasterid": ("id", "global"), + "sldlayoutid": ("id", "global"), + "cm": ("authorid", "file"), + "sheet": ("sheetid", "file"), + "definedname": ("id", "file"), + "cxnsp": ("id", "file"), + "sp": ("id", "file"), + "pic": ("id", "file"), + "grpsp": ("id", "file"), + } + + EXCLUDED_ID_CONTAINERS = { + "sectionlst", + } + + ELEMENT_RELATIONSHIP_TYPES = {} + + SCHEMA_MAPPINGS = { + "word": "ISO-IEC29500-4_2016/wml.xsd", + "ppt": "ISO-IEC29500-4_2016/pml.xsd", + "xl": "ISO-IEC29500-4_2016/sml.xsd", + "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", + "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", + "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + ".rels": "ecma/fouth-edition/opc-relationships.xsd", + "people.xml": "microsoft/wml-2012.xsd", + "commentsIds.xml": "microsoft/wml-cid-2016.xsd", + "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", + "commentsExtended.xml": "microsoft/wml-2012.xsd", + "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", + "theme": "ISO-IEC29500-4_2016/dml-main.xsd", + "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", + } + + MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" + XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + PACKAGE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/relationships" + ) + OFFICE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ) + CONTENT_TYPES_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/content-types" + ) + + MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} + + OOXML_NAMESPACES = { + "http://schemas.openxmlformats.org/officeDocument/2006/math", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "http://schemas.openxmlformats.org/drawingml/2006/picture", + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", + "http://www.w3.org/XML/1998/namespace", + } + + def __init__(self, unpacked_dir, original_file=None, verbose=False): + self.unpacked_dir = Path(unpacked_dir).resolve() + self.original_file = Path(original_file) if original_file else None + self.verbose = verbose + + self.schemas_dir = Path(__file__).parent.parent / "schemas" + + patterns = ["*.xml", "*.rels"] + self.xml_files = [ + f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) + ] + + if not self.xml_files: + print(f"Warning: No XML files found in {self.unpacked_dir}") + + def validate(self): + raise NotImplementedError("Subclasses must implement the validate method") + + def repair(self) -> int: + return self.repair_whitespace_preservation() + + def repair_whitespace_preservation(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if elem.tagName.endswith(":t") and elem.firstChild: + text = elem.firstChild.nodeValue + if text and (text.startswith((' ', '\t')) or text.endswith((' ', '\t'))): + if elem.getAttribute("xml:space") != "preserve": + elem.setAttribute("xml:space", "preserve") + text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text) + print(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}") + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + def validate_xml(self): + errors = [] + + for xml_file in self.xml_files: + try: + lxml.etree.parse(str(xml_file)) + except lxml.etree.XMLSyntaxError as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {e.lineno}: {e.msg}" + ) + except Exception as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Unexpected error: {str(e)}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} XML violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All XML files are well-formed") + return True + + def validate_namespaces(self): + errors = [] + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + declared = set(root.nsmap.keys()) - {None} + + for attr_val in [ + v for k, v in root.attrib.items() if k.endswith("Ignorable") + ]: + undeclared = set(attr_val.split()) - declared + errors.extend( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Namespace '{ns}' in Ignorable but not declared" + for ns in undeclared + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} namespace issues:") + for error in errors: + print(error) + return False + if self.verbose: + print("PASSED - All namespace prefixes properly declared") + return True + + def validate_unique_ids(self): + errors = [] + global_ids = {} + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + file_ids = {} + + mc_elements = root.xpath( + ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} + ) + for elem in mc_elements: + elem.getparent().remove(elem) + + for elem in root.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag = ( + elem.tag.split("}")[-1].lower() + if "}" in elem.tag + else elem.tag.lower() + ) + + if tag in self.UNIQUE_ID_REQUIREMENTS: + in_excluded_container = any( + ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS + for ancestor in elem.iterancestors() + ) + if in_excluded_container: + continue + + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] + + id_value = None + for attr, value in elem.attrib.items(): + attr_local = ( + attr.split("}")[-1].lower() + if "}" in attr + else attr.lower() + ) + if attr_local == attr_name: + id_value = value + break + + if id_value is not None: + if scope == "global": + if id_value in global_ids: + prev_file, prev_line, prev_tag = global_ids[ + id_value + ] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " + f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" + ) + else: + global_ids[id_value] = ( + xml_file.relative_to(self.unpacked_dir), + elem.sourceline, + tag, + ) + elif scope == "file": + key = (tag, attr_name) + if key not in file_ids: + file_ids[key] = {} + + if id_value in file_ids[key]: + prev_line = file_ids[key][id_value] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " + f"(first occurrence at line {prev_line})" + ) + else: + file_ids[key][id_value] = elem.sourceline + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} ID uniqueness violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All required IDs are unique") + return True + + def validate_file_references(self): + errors = [] + + rels_files = list(self.unpacked_dir.rglob("*.rels")) + + if not rels_files: + if self.verbose: + print("PASSED - No .rels files found") + return True + + all_files = [] + for file_path in self.unpacked_dir.rglob("*"): + if ( + file_path.is_file() + and file_path.name != "[Content_Types].xml" + and not file_path.name.endswith(".rels") + ): + all_files.append(file_path.resolve()) + + all_referenced_files = set() + + if self.verbose: + print( + f"Found {len(rels_files)} .rels files and {len(all_files)} target files" + ) + + for rels_file in rels_files: + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + rels_dir = rels_file.parent + + referenced_files = set() + broken_refs = [] + + for rel in rels_root.findall( + ".//ns:Relationship", + namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, + ): + target = rel.get("Target") + if target and not target.startswith( + ("http", "mailto:") + ): + if target.startswith("/"): + target_path = self.unpacked_dir / target.lstrip("/") + elif rels_file.name == ".rels": + target_path = self.unpacked_dir / target + else: + base_dir = rels_dir.parent + target_path = base_dir / target + + try: + target_path = target_path.resolve() + if target_path.exists() and target_path.is_file(): + referenced_files.add(target_path) + all_referenced_files.add(target_path) + else: + broken_refs.append((target, rel.sourceline)) + except (OSError, ValueError): + broken_refs.append((target, rel.sourceline)) + + if broken_refs: + rel_path = rels_file.relative_to(self.unpacked_dir) + for broken_ref, line_num in broken_refs: + errors.append( + f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" + ) + + except Exception as e: + rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append(f" Error parsing {rel_path}: {e}") + + unreferenced_files = set(all_files) - all_referenced_files + + if unreferenced_files: + for unref_file in sorted(unreferenced_files): + unref_rel_path = unref_file.relative_to(self.unpacked_dir) + errors.append(f" Unreferenced file: {unref_rel_path}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship validation errors:") + for error in errors: + print(error) + print( + "CRITICAL: These errors will cause the document to appear corrupt. " + + "Broken references MUST be fixed, " + + "and unreferenced files MUST be referenced or removed." + ) + return False + else: + if self.verbose: + print( + "PASSED - All references are valid and all files are properly referenced" + ) + return True + + def validate_all_relationship_ids(self): + import lxml.etree + + errors = [] + + for xml_file in self.xml_files: + if xml_file.suffix == ".rels": + continue + + rels_dir = xml_file.parent / "_rels" + rels_file = rels_dir / f"{xml_file.name}.rels" + + if not rels_file.exists(): + continue + + try: + rels_root = lxml.etree.parse(str(rels_file)).getroot() + rid_to_type = {} + + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rid = rel.get("Id") + rel_type = rel.get("Type", "") + if rid: + if rid in rid_to_type: + rels_rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append( + f" {rels_rel_path}: Line {rel.sourceline}: " + f"Duplicate relationship ID '{rid}' (IDs must be unique)" + ) + type_name = ( + rel_type.split("/")[-1] if "/" in rel_type else rel_type + ) + rid_to_type[rid] = type_name + + xml_root = lxml.etree.parse(str(xml_file)).getroot() + + r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE + rid_attrs_to_check = ["id", "embed", "link"] + for elem in xml_root.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + for attr_name in rid_attrs_to_check: + rid_attr = elem.get(f"{{{r_ns}}}{attr_name}") + if not rid_attr: + continue + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + elem_name = ( + elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + ) + + if rid_attr not in rid_to_type: + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' " + f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" + ) + elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES: + expected_type = self._get_expected_relationship_type( + elem_name + ) + if expected_type: + actual_type = rid_to_type[rid_attr] + if expected_type not in actual_type.lower(): + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " + f"but should point to a '{expected_type}' relationship" + ) + + except Exception as e: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + errors.append(f" Error processing {xml_rel_path}: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship ID reference errors:") + for error in errors: + print(error) + print("\nThese ID mismatches will cause the document to appear corrupt!") + return False + else: + if self.verbose: + print("PASSED - All relationship ID references are valid") + return True + + def _get_expected_relationship_type(self, element_name): + elem_lower = element_name.lower() + + if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: + return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] + + if elem_lower.endswith("id") and len(elem_lower) > 2: + prefix = elem_lower[:-2] + if prefix.endswith("master"): + return prefix.lower() + elif prefix.endswith("layout"): + return prefix.lower() + else: + if prefix == "sld": + return "slide" + return prefix.lower() + + if elem_lower.endswith("reference") and len(elem_lower) > 9: + prefix = elem_lower[:-9] + return prefix.lower() + + return None + + def validate_content_types(self): + errors = [] + + content_types_file = self.unpacked_dir / "[Content_Types].xml" + if not content_types_file.exists(): + print("FAILED - [Content_Types].xml file not found") + return False + + try: + root = lxml.etree.parse(str(content_types_file)).getroot() + declared_parts = set() + declared_extensions = set() + + for override in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" + ): + part_name = override.get("PartName") + if part_name is not None: + declared_parts.add(part_name.lstrip("/")) + + for default in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" + ): + extension = default.get("Extension") + if extension is not None: + declared_extensions.add(extension.lower()) + + declarable_roots = { + "sld", + "sldLayout", + "sldMaster", + "presentation", + "document", + "workbook", + "worksheet", + "theme", + } + + media_extensions = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + } + + all_files = list(self.unpacked_dir.rglob("*")) + all_files = [f for f in all_files if f.is_file()] + + for xml_file in self.xml_files: + path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( + "\\", "/" + ) + + if any( + skip in path_str + for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] + ): + continue + + try: + root_tag = lxml.etree.parse(str(xml_file)).getroot().tag + root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag + + if root_name in declarable_roots and path_str not in declared_parts: + errors.append( + f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" + ) + + except Exception: + continue + + for file_path in all_files: + if file_path.suffix.lower() in {".xml", ".rels"}: + continue + if file_path.name == "[Content_Types].xml": + continue + if "_rels" in file_path.parts or "docProps" in file_path.parts: + continue + + extension = file_path.suffix.lstrip(".").lower() + if extension and extension not in declared_extensions: + if extension in media_extensions: + relative_path = file_path.relative_to(self.unpacked_dir) + errors.append( + f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' + ) + + except Exception as e: + errors.append(f" Error parsing [Content_Types].xml: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} content type declaration errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print( + "PASSED - All content files are properly declared in [Content_Types].xml" + ) + return True + + def validate_file_against_xsd(self, xml_file, verbose=False): + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + + is_valid, current_errors = self._validate_single_file_xsd( + xml_file, unpacked_dir + ) + + if is_valid is None: + return None, set() + elif is_valid: + return True, set() + + original_errors = self._get_original_file_errors(xml_file) + + assert current_errors is not None + new_errors = current_errors - original_errors + + new_errors = { + e for e in new_errors + if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS) + } + + if new_errors: + if verbose: + relative_path = xml_file.relative_to(unpacked_dir) + print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") + for error in list(new_errors)[:3]: + truncated = error[:250] + "..." if len(error) > 250 else error + print(f" - {truncated}") + return False, new_errors + else: + if verbose: + print( + f"PASSED - No new errors (original had {len(current_errors)} errors)" + ) + return True, set() + + def validate_against_xsd(self): + new_errors = [] + original_error_count = 0 + valid_count = 0 + skipped_count = 0 + + for xml_file in self.xml_files: + relative_path = str(xml_file.relative_to(self.unpacked_dir)) + is_valid, new_file_errors = self.validate_file_against_xsd( + xml_file, verbose=False + ) + + if is_valid is None: + skipped_count += 1 + continue + elif is_valid and not new_file_errors: + valid_count += 1 + continue + elif is_valid: + original_error_count += 1 + valid_count += 1 + continue + + new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") + for error in list(new_file_errors)[:3]: + new_errors.append( + f" - {error[:250]}..." if len(error) > 250 else f" - {error}" + ) + + if self.verbose: + print(f"Validated {len(self.xml_files)} files:") + print(f" - Valid: {valid_count}") + print(f" - Skipped (no schema): {skipped_count}") + if original_error_count: + print(f" - With original errors (ignored): {original_error_count}") + print( + f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" + ) + + if new_errors: + print("\nFAILED - Found NEW validation errors:") + for error in new_errors: + print(error) + return False + else: + if self.verbose: + print("\nPASSED - No new XSD validation errors introduced") + return True + + def _get_schema_path(self, xml_file): + if xml_file.name in self.SCHEMA_MAPPINGS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] + + if xml_file.suffix == ".rels": + return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] + + if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] + + if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] + + if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] + + return None + + def _clean_ignorable_namespaces(self, xml_doc): + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + for elem in xml_copy.iter(): + attrs_to_remove = [] + + for attr in elem.attrib: + if "{" in attr: + ns = attr.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + attrs_to_remove.append(attr) + + for attr in attrs_to_remove: + del elem.attrib[attr] + + self._remove_ignorable_elements(xml_copy) + + return lxml.etree.ElementTree(xml_copy) + + def _remove_ignorable_elements(self, root): + elements_to_remove = [] + + for elem in list(root): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + + tag_str = str(elem.tag) + if tag_str.startswith("{"): + ns = tag_str.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + elements_to_remove.append(elem) + continue + + self._remove_ignorable_elements(elem) + + for elem in elements_to_remove: + root.remove(elem) + + def _preprocess_for_mc_ignorable(self, xml_doc): + root = xml_doc.getroot() + + if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: + del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] + + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path): + schema_path = self._get_schema_path(xml_file) + if not schema_path: + return None, None + + try: + with open(schema_path, "rb") as xsd_file: + parser = lxml.etree.XMLParser() + xsd_doc = lxml.etree.parse( + xsd_file, parser=parser, base_url=str(schema_path) + ) + schema = lxml.etree.XMLSchema(xsd_doc) + + with open(xml_file, "r") as f: + xml_doc = lxml.etree.parse(f) + + xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) + xml_doc = self._preprocess_for_mc_ignorable(xml_doc) + + relative_path = xml_file.relative_to(base_path) + if ( + relative_path.parts + and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS + ): + xml_doc = self._clean_ignorable_namespaces(xml_doc) + + if schema.validate(xml_doc): + return True, set() + else: + errors = set() + for error in schema.error_log: + errors.add(error.message) + return False, errors + + except Exception as e: + return False, {str(e)} + + def _get_original_file_errors(self, xml_file): + if self.original_file is None: + return set() + + import tempfile + import zipfile + + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + relative_path = xml_file.relative_to(unpacked_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_path) + + original_xml_file = temp_path / relative_path + + if not original_xml_file.exists(): + return set() + + is_valid, errors = self._validate_single_file_xsd( + original_xml_file, temp_path + ) + return errors if errors else set() + + def _remove_template_tags_from_text_nodes(self, xml_doc): + warnings = [] + template_pattern = re.compile(r"\{\{[^}]*\}\}") + + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + def process_text_content(text, content_type): + if not text: + return text + matches = list(template_pattern.finditer(text)) + if matches: + for match in matches: + warnings.append( + f"Found template tag in {content_type}: {match.group()}" + ) + return template_pattern.sub("", text) + return text + + for elem in xml_copy.iter(): + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag_str = str(elem.tag) + if tag_str.endswith("}t") or tag_str == "t": + continue + + elem.text = process_text_content(elem.text, "text content") + elem.tail = process_text_content(elem.tail, "tail content") + + return lxml.etree.ElementTree(xml_copy), warnings + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/docx/scripts/office/validators/docx.py b/skills/docx/scripts/office/validators/docx.py new file mode 100644 index 0000000..fec405e --- /dev/null +++ b/skills/docx/scripts/office/validators/docx.py @@ -0,0 +1,446 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import random +import re +import tempfile +import zipfile + +import defusedxml.minidom +import lxml.etree + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml" + W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid" + + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_whitespace_preservation(): + all_valid = False + + if not self.validate_deletions(): + all_valid = False + + if not self.validate_insertions(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_id_constraints(): + all_valid = False + + if not self.validate_comment_markers(): + all_valid = False + + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + if re.search(r"^[ \t\n\r]", text) or re.search( + r"[ \t\n\r]$", text + ): + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces): + if t_elem.text: + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + for instr_elem in root.xpath( + ".//w:del//w:instrText", namespaces=namespaces + ): + text_preview = ( + repr(instr_elem.text or "")[:50] + "..." + if len(repr(instr_elem.text or "")) > 50 + else repr(instr_elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {instr_elem.sourceline}: found within (use ): {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + count = 0 + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + original = self.original_file + if original is None: + return 0 + + count = 0 + + try: + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(original, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + original_count = self.count_paragraphs_in_original() + new_count = self.count_paragraphs_in_unpacked() + + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + def _parse_id_value(self, val: str, base: int = 16) -> int: + return int(val, base) + + def validate_id_constraints(self): + errors = [] + para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId" + durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId" + + for xml_file in self.xml_files: + try: + for elem in lxml.etree.parse(str(xml_file)).iter(): + if val := elem.get(para_id_attr): + if self._parse_id_value(val, base=16) >= 0x80000000: + errors.append( + f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000" + ) + + if val := elem.get(durable_id_attr): + if xml_file.name == "numbering.xml": + try: + if self._parse_id_value(val, base=10) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except ValueError: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} must be decimal in numbering.xml" + ) + else: + if self._parse_id_value(val, base=16) >= 0x7FFFFFFF: + errors.append( + f" {xml_file.name}:{elem.sourceline}: " + f"durableId={val} >= 0x7FFFFFFF" + ) + except Exception: + pass + + if errors: + print(f"FAILED - {len(errors)} ID constraint violations:") + for e in errors: + print(e) + elif self.verbose: + print("PASSED - All paraId/durableId values within constraints") + return not errors + + def validate_comment_markers(self): + errors = [] + + document_xml = None + comments_xml = None + for xml_file in self.xml_files: + if xml_file.name == "document.xml" and "word" in str(xml_file): + document_xml = xml_file + elif xml_file.name == "comments.xml": + comments_xml = xml_file + + if not document_xml: + if self.verbose: + print("PASSED - No document.xml found (skipping comment validation)") + return True + + try: + doc_root = lxml.etree.parse(str(document_xml)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + range_starts = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeStart", namespaces=namespaces + ) + } + range_ends = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentRangeEnd", namespaces=namespaces + ) + } + references = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in doc_root.xpath( + ".//w:commentReference", namespaces=namespaces + ) + } + + orphaned_ends = range_ends - range_starts + for comment_id in sorted( + orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart' + ) + + orphaned_starts = range_starts - range_ends + for comment_id in sorted( + orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + errors.append( + f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd' + ) + + comment_ids = set() + if comments_xml and comments_xml.exists(): + comments_root = lxml.etree.parse(str(comments_xml)).getroot() + comment_ids = { + elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id") + for elem in comments_root.xpath( + ".//w:comment", namespaces=namespaces + ) + } + + marker_ids = range_starts | range_ends | references + invalid_refs = marker_ids - comment_ids + for comment_id in sorted( + invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0 + ): + if comment_id: + errors.append( + f' document.xml: marker id="{comment_id}" references non-existent comment' + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append(f" Error parsing XML: {e}") + + if errors: + print(f"FAILED - {len(errors)} comment marker violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All comment markers properly paired") + return True + + def repair(self) -> int: + repairs = super().repair() + repairs += self.repair_durableId() + return repairs + + def repair_durableId(self) -> int: + repairs = 0 + + for xml_file in self.xml_files: + try: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + modified = False + + for elem in dom.getElementsByTagName("*"): + if not elem.hasAttribute("w16cid:durableId"): + continue + + durable_id = elem.getAttribute("w16cid:durableId") + needs_repair = False + + if xml_file.name == "numbering.xml": + try: + needs_repair = ( + self._parse_id_value(durable_id, base=10) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + else: + try: + needs_repair = ( + self._parse_id_value(durable_id, base=16) >= 0x7FFFFFFF + ) + except ValueError: + needs_repair = True + + if needs_repair: + value = random.randint(1, 0x7FFFFFFE) + if xml_file.name == "numbering.xml": + new_id = str(value) + else: + new_id = f"{value:08X}" + + elem.setAttribute("w16cid:durableId", new_id) + print( + f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}" + ) + repairs += 1 + modified = True + + if modified: + xml_file.write_bytes(dom.toxml(encoding="UTF-8")) + + except Exception: + pass + + return repairs + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/docx/scripts/office/validators/pptx.py b/skills/docx/scripts/office/validators/pptx.py new file mode 100644 index 0000000..09842aa --- /dev/null +++ b/skills/docx/scripts/office/validators/pptx.py @@ -0,0 +1,275 @@ +""" +Validator for PowerPoint presentation XML files against XSD schemas. +""" + +import re + +from .base import BaseSchemaValidator + + +class PPTXSchemaValidator(BaseSchemaValidator): + + PRESENTATIONML_NAMESPACE = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" + ) + + ELEMENT_RELATIONSHIP_TYPES = { + "sldid": "slide", + "sldmasterid": "slidemaster", + "notesmasterid": "notesmaster", + "sldlayoutid": "slidelayout", + "themeid": "theme", + "tablestyleid": "tablestyles", + } + + def validate(self): + if not self.validate_xml(): + return False + + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + if not self.validate_unique_ids(): + all_valid = False + + if not self.validate_uuid_ids(): + all_valid = False + + if not self.validate_file_references(): + all_valid = False + + if not self.validate_slide_layout_ids(): + all_valid = False + + if not self.validate_content_types(): + all_valid = False + + if not self.validate_against_xsd(): + all_valid = False + + if not self.validate_notes_slide_references(): + all_valid = False + + if not self.validate_all_relationship_ids(): + all_valid = False + + if not self.validate_no_duplicate_slide_layouts(): + all_valid = False + + return all_valid + + def validate_uuid_ids(self): + import lxml.etree + + errors = [] + uuid_pattern = re.compile( + r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" + ) + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + for elem in root.iter(): + for attr, value in elem.attrib.items(): + attr_name = attr.split("}")[-1].lower() + if attr_name == "id" or attr_name.endswith("id"): + if self._looks_like_uuid(value): + if not uuid_pattern.match(value): + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} UUID ID validation errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All UUID-like IDs contain valid hex values") + return True + + def _looks_like_uuid(self, value): + clean_value = value.strip("{}()").replace("-", "") + return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) + + def validate_slide_layout_ids(self): + import lxml.etree + + errors = [] + + slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) + + if not slide_masters: + if self.verbose: + print("PASSED - No slide masters found") + return True + + for slide_master in slide_masters: + try: + root = lxml.etree.parse(str(slide_master)).getroot() + + rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" + + if not rels_file.exists(): + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" + ) + continue + + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + valid_layout_rids = set() + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type: + valid_layout_rids.add(rel.get("Id")) + + for sld_layout_id in root.findall( + f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" + ): + r_id = sld_layout_id.get( + f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" + ) + layout_id = sld_layout_id.get("id") + + if r_id and r_id not in valid_layout_rids: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " + f"references r:id='{r_id}' which is not found in slide layout relationships" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") + for error in errors: + print(error) + print( + "Remove invalid references or add missing slide layouts to the relationships file." + ) + return False + else: + if self.verbose: + print("PASSED - All slide layout IDs reference valid slide layouts") + return True + + def validate_no_duplicate_slide_layouts(self): + import lxml.etree + + errors = [] + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + layout_rels = [ + rel + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ) + if "slideLayout" in rel.get("Type", "") + ] + + if len(layout_rels) > 1: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" + ) + + except Exception as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print("FAILED - Found slides with duplicate slideLayout references:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All slides have exactly one slideLayout reference") + return True + + def validate_notes_slide_references(self): + import lxml.etree + + errors = [] + notes_slide_references = {} + + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + if not slide_rels_files: + if self.verbose: + print("PASSED - No slide relationship files found") + return True + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "notesSlide" in rel_type: + target = rel.get("Target", "") + if target: + normalized_target = target.replace("../", "") + + slide_name = rels_file.stem.replace( + ".xml", "" + ) + + if normalized_target not in notes_slide_references: + notes_slide_references[normalized_target] = [] + notes_slide_references[normalized_target].append( + (slide_name, rels_file) + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + for target, references in notes_slide_references.items(): + if len(references) > 1: + slide_names = [ref[0] for ref in references] + errors.append( + f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" + ) + for slide_name, rels_file in references: + errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") + + if errors: + print( + f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" + ) + for error in errors: + print(error) + print("Each slide may optionally have its own slide file.") + return False + else: + if self.verbose: + print("PASSED - All notes slide references are unique") + return True + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/docx/scripts/office/validators/redlining.py b/skills/docx/scripts/office/validators/redlining.py new file mode 100644 index 0000000..71c81b6 --- /dev/null +++ b/skills/docx/scripts/office/validators/redlining.py @@ -0,0 +1,247 @@ +""" +Validator for tracked changes in Word documents. +""" + +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +class RedliningValidator: + + def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"): + self.unpacked_dir = Path(unpacked_dir) + self.original_docx = Path(original_docx) + self.verbose = verbose + self.author = author + self.namespaces = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + } + + def repair(self) -> int: + return 0 + + def validate(self): + modified_file = self.unpacked_dir / "word" / "document.xml" + if not modified_file.exists(): + print(f"FAILED - Modified document.xml not found at {modified_file}") + return False + + try: + import xml.etree.ElementTree as ET + + tree = ET.parse(modified_file) + root = tree.getroot() + + del_elements = root.findall(".//w:del", self.namespaces) + ins_elements = root.findall(".//w:ins", self.namespaces) + + author_del_elements = [ + elem + for elem in del_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + author_ins_elements = [ + elem + for elem in ins_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == self.author + ] + + if not author_del_elements and not author_ins_elements: + if self.verbose: + print(f"PASSED - No tracked changes by {self.author} found.") + return True + + except Exception: + pass + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + try: + with zipfile.ZipFile(self.original_docx, "r") as zip_ref: + zip_ref.extractall(temp_path) + except Exception as e: + print(f"FAILED - Error unpacking original docx: {e}") + return False + + original_file = temp_path / "word" / "document.xml" + if not original_file.exists(): + print( + f"FAILED - Original document.xml not found in {self.original_docx}" + ) + return False + + try: + import xml.etree.ElementTree as ET + + modified_tree = ET.parse(modified_file) + modified_root = modified_tree.getroot() + original_tree = ET.parse(original_file) + original_root = original_tree.getroot() + except ET.ParseError as e: + print(f"FAILED - Error parsing XML files: {e}") + return False + + self._remove_author_tracked_changes(original_root) + self._remove_author_tracked_changes(modified_root) + + modified_text = self._extract_text_content(modified_root) + original_text = self._extract_text_content(original_root) + + if modified_text != original_text: + error_message = self._generate_detailed_diff( + original_text, modified_text + ) + print(error_message) + return False + + if self.verbose: + print(f"PASSED - All changes by {self.author} are properly tracked") + return True + + def _generate_detailed_diff(self, original_text, modified_text): + error_parts = [ + f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes", + "", + "Likely causes:", + " 1. Modified text inside another author's or tags", + " 2. Made edits without proper tracked changes", + " 3. Didn't nest inside when deleting another's insertion", + "", + "For pre-redlined documents, use correct patterns:", + " - To reject another's INSERTION: Nest inside their ", + " - To restore another's DELETION: Add new AFTER their ", + "", + ] + + git_diff = self._get_git_word_diff(original_text, modified_text) + if git_diff: + error_parts.extend(["Differences:", "============", git_diff]) + else: + error_parts.append("Unable to generate word diff (git not available)") + + return "\n".join(error_parts) + + def _get_git_word_diff(self, original_text, modified_text): + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + original_file = temp_path / "original.txt" + modified_file = temp_path / "modified.txt" + + original_file.write_text(original_text, encoding="utf-8") + modified_file.write_text(modified_text, encoding="utf-8") + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "--word-diff-regex=.", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + + if content_lines: + return "\n".join(content_lines) + + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "-U0", + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + return "\n".join(content_lines) + + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + pass + + return None + + def _remove_author_tracked_changes(self, root): + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + author_attr = f"{{{self.namespaces['w']}}}author" + + for parent in root.iter(): + to_remove = [] + for child in parent: + if child.tag == ins_tag and child.get(author_attr) == self.author: + to_remove.append(child) + for elem in to_remove: + parent.remove(elem) + + deltext_tag = f"{{{self.namespaces['w']}}}delText" + t_tag = f"{{{self.namespaces['w']}}}t" + + for parent in root.iter(): + to_process = [] + for child in parent: + if child.tag == del_tag and child.get(author_attr) == self.author: + to_process.append((child, list(parent).index(child))) + + for del_elem, del_index in reversed(to_process): + for elem in del_elem.iter(): + if elem.tag == deltext_tag: + elem.tag = t_tag + + for child in reversed(list(del_elem)): + parent.insert(del_index, child) + parent.remove(del_elem) + + def _extract_text_content(self, root): + p_tag = f"{{{self.namespaces['w']}}}p" + t_tag = f"{{{self.namespaces['w']}}}t" + + paragraphs = [] + for p_elem in root.findall(f".//{p_tag}"): + text_parts = [] + for t_elem in p_elem.findall(f".//{t_tag}"): + if t_elem.text: + text_parts.append(t_elem.text) + paragraph_text = "".join(text_parts) + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/docx/scripts/templates/comments.xml b/skills/docx/scripts/templates/comments.xml new file mode 100644 index 0000000..cd01a7d --- /dev/null +++ b/skills/docx/scripts/templates/comments.xml @@ -0,0 +1,3 @@ + + + diff --git a/skills/docx/scripts/templates/commentsExtended.xml b/skills/docx/scripts/templates/commentsExtended.xml new file mode 100644 index 0000000..411003c --- /dev/null +++ b/skills/docx/scripts/templates/commentsExtended.xml @@ -0,0 +1,3 @@ + + + diff --git a/skills/docx/scripts/templates/commentsExtensible.xml b/skills/docx/scripts/templates/commentsExtensible.xml new file mode 100644 index 0000000..f5572d7 --- /dev/null +++ b/skills/docx/scripts/templates/commentsExtensible.xml @@ -0,0 +1,3 @@ + + + diff --git a/skills/docx/scripts/templates/commentsIds.xml b/skills/docx/scripts/templates/commentsIds.xml new file mode 100644 index 0000000..32f1629 --- /dev/null +++ b/skills/docx/scripts/templates/commentsIds.xml @@ -0,0 +1,3 @@ + + + diff --git a/skills/docx/scripts/templates/people.xml b/skills/docx/scripts/templates/people.xml new file mode 100644 index 0000000..3803d2d --- /dev/null +++ b/skills/docx/scripts/templates/people.xml @@ -0,0 +1,3 @@ + + + diff --git a/skills/farming-postcard/SKILL.md b/skills/farming-postcard/SKILL.md new file mode 100755 index 0000000..36c461c --- /dev/null +++ b/skills/farming-postcard/SKILL.md @@ -0,0 +1,275 @@ +--- +name: farming-postcard +description: Generate print-ready 6x4 farming postcards for Graeham Watts in his locked brand system (gold + black, chevron pattern, Intero + Graeham Watts lockup). Three workflows — interactive create on demand, scheduled preview that emails 3-5 hook options 7 days before the 1st and 15th of each month, and recall of previously-emailed options for selection. Use ANY time the user mentions farming postcard, direct mail postcard, EPA postcard, neighborhood postcard, mailer, mail piece, Wise Pelican, Universal Mail Works, Corefact, ProspectsPLUS, monthly postcard, 1st of the month postcard, 15th of the month postcard, equity postcard, buyer-tagged postcard, anti-Zillow postcard, neighbor envy postcard, AI search postcard, new postcard for next month, the next farming card, run the postcard skill, design a postcard, "pull up the postcard options you emailed", "what did you send me for the next postcard", "show me the postcard previews", "pick one of the postcard hooks". Also trigger when user uploads past postcards as reference and asks for the next one in the series, or says things like "what's the hook for next month" or "we need a postcard for the first" or "let's do the 15th card." Encodes Graeham's 6 historical headline archetypes (equity, buyer-tagged, anti-Zillow buyer pool, AI search, anti-Zestimate, neighbor envy), the locked contact block continuity rule, CTA→landing page router, Universal Mail Works print specs, and an option-cache for scheduled previews. +--- + +# Farming Postcard Skill + +## Purpose + +Generate Graeham Watts farming postcards that match the locked brand system used across past mailings, with one-shot per-card customization. Every card ships with the same visual continuity (gold border, chevron pattern, identical bottom contact lockup) so the audience recognizes the sender instantly. Only the **hook + back copy + QR target** changes per card. + +**Why the lockdown matters:** Direct mail works on repetition. Recognition before reading is the entire point. The bottom contact block, logo lockup, color palette, and disclaimer placement are NEVER negotiable — they are the brand signature. + +## Three workflows + +| Workflow | Trigger | Output | +|---|---|---| +| **A — Interactive create** | User says "make a postcard for [date]" | HTML preview + print-ready PDF in Downloads | +| **B — Scheduled preview** | Cron: 8th + 24th of month at 8am | Email to graehamwatts@gmail.com with 3-5 hook options + cache to option-cache.md | +| **C — Recall emailed options** | User says "pull up what you emailed me" | Read cached options, present in chat, user picks one → run Workflow A on the choice | + +--- + +## Workflow A — Interactive create (user requests a postcard now) + +### Step 1 — Gather inputs + +Ask the following IN ORDER, using `AskUserQuestion` where multiple-choice makes sense. + +**Q1: Mail date?** (e.g., "06/01/26"). Drives filename `Farming_Postcard_EPA_[MM_DD_YY].pdf` and auto-suggests an archetype. + +**Q2: Audience?** Farm only / Past clients only / Both (generic — most common). + +**Q3: Hook angle?** Offer the 6 archetypes from `references/headline-library.md`: +1. **Equity** (pride/curiosity) +2. **Buyer-tagged** (scarcity) +3. **Anti-Zillow buyer pool** (scarcity + anti-portal) +4. **AI search invisibility** (FOMO + tech) +5. **Anti-Zestimate** (anti-algorithm) +6. **Neighbor envy** (curiosity + social proof) +7. **Custom** (user-provided) + +**Cadence default suggestions:** +- 1st of month → **Equity** (matches historical 1st-of-month pattern) +- 15th of month → **Buyer pool / Tech / Scarcity** +- Don't repeat same archetype within 3 cards (check `headline-library.md` "Cards shipped" table) + +**Q4: CTA type?** Drives QR target via `references/cta-router.md`: +- Home valuation / Testimonials / Free report (market) / Free report (AI score) / Thinking of selling / Off-market buyers / Call-text Graeham / Custom URL + +If the CTA type's URL is `[NOT SET]` in `cta-router.md`, ask user once, then CACHE it by editing the file. Never ask twice. + +**Q5: Live data to bake in?** Optional. If user provides a number, FLAG: "Verify before print — never fabricate." + +### Step 2 — Generate headline + +Use chosen archetype from `references/headline-library.md`. Don't copy past headlines verbatim — pull the **lever** and rebuild fresh language using the remix patterns there. + +**Gold-highlight rules:** 1-3 words max per headline. Solid gold fill for short emphasized phrases; gold underline for action verbs. + +### Step 3 — Build back copy + +Structure (always): +1. **Headline** (Anton ~26pt) — gold-box-wrapped key word +2. **Italic body** (Inter 10pt, max 3 sentences) — proof + differentiation +3. **CTA line** (Anton, gold) — what they get +4. **QR + scan label** — "Scan to see your [thing] today" + +### Step 4 — Render + +Substitute slots in `templates/postcard-template.html`: +- `{{MAIL_DATE}}`, `{{ARCHETYPE}}`, `{{FRONT_HEADLINE_HTML}}`, `{{FRONT_SUBLINE_HTML}}`, `{{BACK_HEADLINE_HTML}}`, `{{BACK_BODY_HTML}}`, `{{BACK_CTA_LINE}}`, `{{QR_SCAN_LABEL}}`, `{{QR_IMAGE_SRC}}`, `{{FRONT_PHOTO_SRC}}`, `{{BACK_PHOTO_SRC}}` + +**LOCKED — never substitute** (see `references/design-tokens.md`): All design tokens, the bottom contact lockup, gold border, chevron pattern, vertical disclaimer. + +Save HTML preview to: `C:\Users\Admin\Downloads\Farming_Postcard_[MM_DD_YY]_PREVIEW.html` + +### Step 5 — Generate print-ready PDF + +Render HTML to PDF at 6.25" × 4.25" (includes 0.125" bleed each side) at 300 DPI using Playwright (see `references/print-specs.md` for the script). Output to `C:\Users\Admin\Downloads\Farming_Postcard_[MM_DD_YY]_PRINT.pdf`. + +### Step 6 — Present + log + +1. `mcp__cowork__present_files` to surface HTML + PDF +2. Append new card row to "Cards shipped" table in `headline-library.md` +3. Offer GitHub sync if skill itself was edited + +### Step 7 — Auto-publish to online archive (MANDATORY) + +Every new card must be added to the public-facing archive at `Graehamwatts/online-content/farming-postcards/` so Graeham (and Claude in future sessions) can see the running history. + +**Steps:** +1. Clone `Graehamwatts/online-content` to /tmp +2. Copy the PDF to `farming-postcards/pdfs/[YYYY-MM-DD]-[archetype-slug].pdf` +3. Generate a thumbnail (page 1, 100 DPI JPG via `pdftoppm`) to `farming-postcards/thumbnails/` +4. If the card is preview-only (no PDF yet), copy the HTML preview to `farming-postcards/[YYYY-MM-DD]-[archetype-slug]-preview.html` +5. Append the new card entry to `farming-postcards/archive.json` under `cards[]` +6. Regenerate `farming-postcards/index.html` from `archive.json` — add a new card to the grid at the top +7. Commit: `Add [YYYY-MM-DD] [archetype] postcard to archive` and push to main + +**Live dashboard URL:** https://graehamwatts.github.io/online-content/farming-postcards/ + +**Archive entry format (matches existing archive.json schema):** +```json +{ + "id": "YYYY-MM-DD-archetype-slug", + "mail_date": "YYYY-MM-DD", + "archetype": "[Equity / Buyer-tagged / etc.]", + "lever": "[psychological lever]", + "front_headline": "[plain text]", + "back_headline": "[plain text]", + "cta_type": "[CTA type from cta-router]", + "cta_line": "[gold CTA tagline]", + "audience": "[Farm / Past clients / Both]", + "pdf": "pdfs/YYYY-MM-DD-archetype-slug.pdf", + "thumbnail": "thumbnails/YYYY-MM-DD-archetype-slug-1.jpg", + "notes": "[one-line note about the card]" +} +``` + +--- + +## Workflow B — Scheduled preview (cron-triggered, no user in the loop) + +**Trigger:** Scheduled task fires on the 8th of each month (7 days before the 15th drop) and on the 24th (7 days before the 1st of next month) at 8am. + +### Step B1 — Calculate target mail date + +- If today is the 8th → target date is the 15th of this month +- If today is the 24th → target date is the 1st of NEXT month + +### Step B2 — Pick 3-5 archetype options + +1. Read `references/headline-library.md` "Cards shipped" table +2. Get the last 3 archetypes used +3. Pick 3-5 fresh archetypes (NOT in the last 3) honoring cadence: + - Target = 1st → bias toward Equity, Anti-Zestimate, Neighbor envy + - Target = 15th → bias toward Buyer-tagged, Anti-Zillow pool, AI search + +### Step B3 — Generate hook options + +For EACH archetype picked, generate: +- A fresh headline (using the remix patterns, not copy/paste) +- One-line "why this works" rationale +- Suggested back-headline + CTA line +- Suggested CTA type (drives QR target) + +Format as a clean comparison table. + +### Step B4 — Send email to Graeham + Peter (REAL SEND via SMTP) + +**Recipients (LOCKED — always both):** +- graehamwatts@gmail.com (Graeham) +- graehamwattsvideo@gmail.com (Peter, also goes by Jason) + +**Method:** Use the SMTP send script (NOT Gmail MCP draft — the MCP only supports drafts which Graeham won't see). The script reads his Gmail App Password from `C:\Users\Graeham Watts\Documents\Claude\Skills\gmail-app-password.txt`. + +```bash +python "C:\Users\Graeham Watts\Documents\Claude\Skills\skills\farming-postcard\scripts\send_options_email.py" \ + "" \ + "Postcard options for [TARGET_MAIL_DATE] — pick one by [PICK_DEADLINE]" \ + "" +``` + +**HTML body:** Build by substituting slots in `templates/options-email-template.html` and `templates/option-card.html`. Both templates use the locked brand system (white postcard look, gold left border, cream option panels, Anton headlines, INTERO + Graeham Watts lockup at bottom). DO NOT use the dark dashboard style — that's not on-brand. + +**Subject format:** `Postcard options for [Date] — pick one by [Deadline]` +Example: `Postcard options for June 15 — pick one by June 12` + +**Plaintext fallback:** Always include a plaintext version for clients that don't render HTML. Short bullet list of options. + +**Error handling:** If `gmail-app-password.txt` is missing or returns 401 (invalid/expired password), abort the send, log to schedule-log.md as `email_status=failed_no_credential`, and create a fallback Gmail draft as a safety net so the options aren't lost. + +### Step B5 — Cache options + +Append to `references/option-cache.md`: + +```markdown +## [TARGET_DATE] (emailed [SENT_DATE]) +Status: pending pick + +### Option 1 — [Archetype] +Front headline: [text with markup] +Back headline: [text] +CTA: [type] → [URL] +Why: [rationale] + +### Option 2 — [Archetype] +... + +[etc.] +``` + +### Step B6 — Confirm to logs + +Write a one-line entry to `references/schedule-log.md`: `[timestamp] Emailed [N] options for [target date], cached at option-cache.md` + +--- + +## Workflow C — Recall emailed options (user is back in Cowork) + +**Trigger:** User says "pull up what you emailed me", "show me the postcard previews", "what did you send for the next card", "pick one of the postcard hooks", etc. + +### Step C1 — Read cache + +Read `references/option-cache.md`. Find the most recent entry with `Status: pending pick`. + +### Step C2 — Present in Cowork + +Show the user a clean side-by-side comparison of the cached options (table or cards). Use the existing markup from the email so it feels like the same brief. + +### Step C3 — User picks + +User picks an option ("use option 2", "let's go with the neighbor envy one"). If user wants to modify the picked option, accept tweaks now. + +### Step C4 — Run Workflow A on the pick + +Hand the chosen option's parameters into Workflow A starting at Step 4 (skip the question flow — answers are already in the cache). + +### Step C5 — Update cache + +Mark that option in `option-cache.md`: `Status: PICKED on [date]`. Mark others: `Status: not picked`. Move the whole entry under a "Resolved" section. + +--- + +## Critical principles (apply to all workflows) + +- **Bottom contact block continuity is sacred.** Never edit it per card. +- **Never fabricate stats.** Verify before any number lands on print. +- **One hook per card.** 3-second glance time. One hook, one CTA, one flip. +- **Two headshots, two moods.** Front = pointing pose; Back = smiling pose. +- **Disclaimer is legally required.** Vertical right edge of back. Never remove. +- **Repetition rule.** Don't reuse the same archetype within 3 cards. + +## Files in this skill + +``` +farming-postcard/ +├── SKILL.md (this file) +├── references/ +│ ├── headline-library.md (6 archetypes + remix + memory) +│ ├── design-tokens.md (locked brand system) +│ ├── cta-router.md (CTA type → URL cache) +│ ├── print-specs.md (UMW + bleed math + Playwright script) +│ ├── option-cache.md (emailed preview options awaiting pick) +│ └── schedule-log.md (cron run history) +└── templates/ + └── postcard-template.html (parameterized master template) +``` + +## Setup (one-time, already done as of build) + +- **GitHub backup:** PAT at `C:\Users\Graeham Watts\Documents\Claude\Skills\github-token.txt` +- **Gmail SMTP:** App Password at `C:\Users\Graeham Watts\Documents\Claude\Skills\gmail-app-password.txt` +- **Scheduled tasks:** + - `farming-postcard-15th-preview` — fires 8th of each month at 8am → SMTP sends options for the 15th + - `farming-postcard-1st-preview` — fires 24th of each month at 8am → SMTP sends options for the 1st of next month +- **Email recipients (LOCKED):** + - graehamwatts@gmail.com (Graeham) + - graehamwattsvideo@gmail.com (Peter, aka Jason) +- **Send method:** SMTP via `scripts/send_options_email.py` (NOT Gmail MCP draft) + +## After a card ships + +1. Append the shipped card to "Cards shipped" table in `headline-library.md` +2. Move the picked option in `option-cache.md` to "Resolved" +3. Run Step 7 (auto-publish to online archive at `Graehamwatts/online-content/farming-postcards/`) +4. Push skill update to GitHub via `github-skill-sync` if any reference files changed + +## Live archive + +All historical and current postcards are tracked at: +- **Dashboard URL:** https://graehamwatts.github.io/online-content/farming-postcards/ +- **Repo path:** `Graehamwatts/online-content/farming-postcards/` +- **Source of truth:** `archive.json` in that folder — regenerate `index.html` from it whenever cards are added diff --git a/skills/farming-postcard/references/cta-router.md b/skills/farming-postcard/references/cta-router.md new file mode 100755 index 0000000..1251156 --- /dev/null +++ b/skills/farming-postcard/references/cta-router.md @@ -0,0 +1,73 @@ +# CTA Router — CTA Type → Landing Page URL + +When a user picks a CTA type for a card, look up the URL here. If the URL is `[NOT SET]`, ask the user once and CACHE it by editing this file. Never ask twice for the same CTA type. + +## Cached URLs + +| CTA Type | Landing URL | Use for | +|---|---|---| +| Home valuation | `[NOT SET — ask user, then cache here]` | "What's my home worth", equity check, precision equity audit, free home valuation | +| Testimonials | `[NOT SET — ask user, then cache here]` | Social proof / reviews CTAs | +| Free report (market) | `[NOT SET — ask user, then cache here]` | Neighborhood report, market report, free download | +| Free report (AI score) | `[NOT SET — ask user, then cache here]` | AI search visibility report, property AI score | +| Thinking of selling | `[NOT SET — ask user, then cache here]` | Pre-listing consultation, seller guide | +| Off-market buyers | `[NOT SET — ask user, then cache here]` | Buyer-pool angle, "I have a list of buyers" | +| Call / text Graeham | `tel:+16503084727` or `sms:+16503084727` | Direct contact CTA (no landing page needed) | +| Custom | Ask user each time | One-off campaigns | + +## How to update + +When asking the user for a missing URL, after they provide it: + +1. Edit this file using the Edit tool +2. Replace `[NOT SET — ask user, then cache here]` with the URL +3. Tell the user: "Cached [URL] as your default [CTA type] target. Won't ask again." + +## QR code generation + +Once you have the URL, generate the QR code at print time. Recommended approach: + +**Python (via bash):** +```bash +pip install qrcode pillow --break-system-packages --quiet +python -c "import qrcode; qr=qrcode.QRCode(box_size=10, border=2); qr.add_data('$URL'); qr.make(); img=qr.make_image(); img.save('/sessions/inspiring-awesome-hawking/mnt/outputs/qr.png')" +``` + +Then embed the resulting PNG into the postcard HTML in place of the stylized SVG placeholder. + +**UTM recommendation (optional but smart):** +Add UTM params so Graeham can track which postcard drove which conversions: +``` +?utm_source=postcard&utm_medium=direct_mail&utm_campaign=epa_[mm_dd_yy]&utm_content=[archetype] +``` + +Example: +``` +https://graehamwatts.com/value?utm_source=postcard&utm_medium=direct_mail&utm_campaign=epa_06_01_26&utm_content=neighbor_envy +``` + +## URL hygiene + +- Always use HTTPS (more reliable QR scanning on iOS) +- Keep URLs under 100 characters or QR density gets too high to scan from arm's length +- If URL is long, use a URL shortener (bit.ly / rebrand.ly) BUT lose UTM tracking — tradeoff to discuss with user + +--- + +## Switchy integration (added 2026-05-28) — pixeled, scan-tracked QR targets + +The QR target should be a **Switchy short link**, not the raw landing URL. The short +link redirects to the landing URL (with UTM) AND fires the retargeting pixel + counts +the scan on the redirect layer — making every postcard drop a retargeting audience, +not just a one-way mailer. Engine: `skills/switchy-engine`. + +**At print time:** +1. Resolve the landing URL from the table above (e.g. Home valuation). +2. Append UTM: `?utm_source=postcard&utm_medium=direct_mail&utm_campaign=epa_[mm_dd_yy]&utm_content=[archetype]`. +3. Mint a Switchy link: `url` = the UTM'd landing URL, `tags:["postcard","qr","consumer","epa_[mm_dd_yy]"]`, `pixels` from `shared-references/switchy.json`. (REST POST https://api.switchy.io/v1/links/create, header `Api-Authorization: `.) +4. Generate the QR encoding the **Switchy short URL**. +5. Report scans later via `switchy-engine/scripts/switchy_analytics.py`. + +If the token isn't active yet, fall back to a QR on the UTM'd landing URL (GA tracks +sessions; no pixel/scan layer). The printed QR can't change later, so mint the Switchy +link BEFORE the print run whenever possible. diff --git a/skills/farming-postcard/references/design-tokens.md b/skills/farming-postcard/references/design-tokens.md new file mode 100755 index 0000000..6559627 --- /dev/null +++ b/skills/farming-postcard/references/design-tokens.md @@ -0,0 +1,124 @@ +# Design Tokens — LOCKED Brand System + +These tokens are NEVER negotiable per card. Continuity is the brand. + +## Colors + +| Token | Hex | CMYK (approx) | Use | +|---|---|---|---| +| Gold (primary) | `#C2A14E` | C:25 M:35 Y:75 K:5 | Border, headline highlights, CTA color, logo roof accent | +| Gold (deep) | `#A88638` | C:30 M:42 Y:85 K:15 | Gradient bottom of gold-box words | +| Gold (light) | `#EAD9A8` | C:8 M:15 Y:40 K:0 | Gradient top of gold-box words, light fills | +| Dark ink | `#1A1D2E` | C:80 M:75 Y:50 K:60 | Headlines, body text, logo | +| Cream | `#FBF7EC` | C:2 M:3 Y:10 K:0 | Back panel background | +| Pattern color | `#E6DABC` (~35% opacity) | n/a | Chevron house pattern overlay | +| White | `#FFFFFF` | 0,0,0,0 | Postcard background | + +## Typography + +| Use | Font | Weight | Size | Source | +|---|---|---|---|---| +| Front headline | Anton | Regular | 38pt | Google Fonts | +| Back headline | Anton | Regular | 26pt | Google Fonts | +| CTA line | Anton | Regular | 14pt | Google Fonts | +| Body | Inter | 400 (italic) | 10pt | Google Fonts | +| Sub / flip prompt | Inter | 600-800 | 14pt | Google Fonts | +| Contact info | Inter | 400/800 | 8-11pt | Google Fonts | +| Disclaimer | Inter | 400 | 6.5pt | Google Fonts | + +**Headline rule:** Anton ONLY. Never substitute. Oswald is acceptable backup if Anton fails to load. + +## Layout grid (6" × 4" postcard) + +- **Gold left border:** 14px wide, full height, color `#C2A14E`, z-index 6 +- **Chevron pattern:** SVG repeat, 80x40px tile, 0.35 stroke opacity, 0.55 layer opacity +- **Bleed:** 0.125" each side (total canvas 6.25" × 4.25") +- **Safe zone:** Keep type 0.25" from all edges minimum + +## LOCKED Bottom Contact Block (NEVER edit) + +This block appears identically on every card. Continuity is the brand signature. + +``` +[INTERO LOGO] +A Berkshire Hathaway Affiliate +[gold roof icon] +GRAEHAM WATTS + +REALTOR® 650-308-4727 +The Martin Team graehamwatts@gmail.com +DRE #01466876 www.graehamwatts.com +``` + +**HTML structure (drop-in):** + +```html + +
+
REALTOR®
+
The Martin Team
+
DRE #01466876
+
650-308-4727
+
graehamwatts@gmail.com
+
www.graehamwatts.com
+
+``` + +## LOCKED Disclaimer (legal — never remove) + +> "If your home is listed with another broker, please disregard this postcard. Homes not necessarily sold by this broker." + +- Placement: Vertical text on right edge of BACK +- Size: 6.5pt Inter +- Color: `#555` +- Rotated -90° + +## Gold-highlight treatments + +Two variants only. Choose per word/phrase: + +**Variant A — Solid gold box** (for short emphasized phrases on light background): +```css +background: linear-gradient(180deg, #EAD9A8 0%, #C2A14E 60%, #A88638 100%); +color: #fff; +padding: 0 6px; +text-shadow: 1px 1px 0 rgba(0,0,0,0.15); +``` + +**Variant B — Gold text fill** (for emphasized words inline with regular headline): +```css +background: linear-gradient(180deg, #EAD9A8, #C2A14E, #A88638); +-webkit-background-clip: text; +background-clip: text; +-webkit-text-fill-color: transparent; +``` + +**Variant C — Gold underline** (for action verbs): +```css +border-bottom: 4px solid #C2A14E; +padding-bottom: 2px; +``` + +## What's NEGOTIABLE per card + +- Headline text + which words get gold highlight (1-3 max) +- Subline / flip prompt copy +- Back headline + body copy +- CTA line text +- QR target URL +- Headshot pose (pointing for front, smiling for back is the default but can flex) + +## What's NEVER negotiable + +- Color tokens above +- Font choices +- Bottom contact block +- Disclaimer text + placement +- Gold left border +- Chevron pattern background +- Aspect ratio (6×4 default — can scale to 6×9 for Corefact jumbo but proportions lock) diff --git a/skills/farming-postcard/references/headline-library.md b/skills/farming-postcard/references/headline-library.md new file mode 100755 index 0000000..20a41c8 --- /dev/null +++ b/skills/farming-postcard/references/headline-library.md @@ -0,0 +1,138 @@ +# Headline Library — Graeham's Farming Postcard Archetypes + +Six proven archetypes from Graeham's past cards, each with the **psychological lever** it pulls and remix patterns for fresh language. + +--- + +## 1. EQUITY (pride + curiosity) + +**Past hook:** "Do You Know How Much Equity You've Built This Year?" +**Lever:** Pride in ownership + curiosity about a real number +**Best for:** 1st-of-month cadence, past clients, owners who haven't sold in 3+ years +**Default CTA:** Home valuation + +**Remix patterns:** +- "Your home grew $___ this year. Want the real number?" +- "EPA equity is at a 5-year high — what's yours?" +- "You're sitting on more than you think." +- "The equity check most owners never get." + +**Gold-highlight rule:** Highlight the dollar amount or "equity" / "more than you think" + +--- + +## 2. BUYER-TAGGED (scarcity) + +**Past hook:** "I Already Have Your Buyer TAGGED" +**Lever:** "Someone wants what you have and you don't know it" +**Best for:** Hot markets, post-spike inventory crunch, 15th-of-month cadence +**Default CTA:** Home valuation or "thinking of selling" + +**Remix patterns:** +- "Your buyer is already in my pipeline." +- "I have 3 buyers looking for your block." +- "There's a family ready to make you an offer." +- "Your home is on someone's list. They just haven't seen it yet." + +**Gold-highlight rule:** Highlight "TAGGED" / "3 buyers" / "your block" + +--- + +## 3. ANTI-ZILLOW BUYER POOL (scarcity + anti-portal) + +**Past hook:** "I Have A List Of Buyers Who Don't Use Zillow" +**Lever:** "There's a market you're not seeing" +**Best for:** Owners who think Zillow tells the whole story; differentiation from competing agents +**Default CTA:** Home valuation or "thinking of selling" + +**Remix patterns:** +- "The buyers Zillow can't show you." +- "Off-market buyers don't browse — they get tagged." +- "73% of my closings start before the listing goes live." *(only use real numbers — verify before print)* +- "Zillow shows everyone. I show the ones who actually buy." + +**Gold-highlight rule:** Highlight "Don't use Zillow" / "Off-market" / the real number + +--- + +## 4. AI SEARCH INVISIBILITY (FOMO + tech) + +**Past hook:** "Why Isn't ChatGPT Recommending Your Home?" +**Lever:** "The market changed and you didn't notice" +**Best for:** Tech-savvy markets (Peninsula), AEO/answer-engine moment, owners who pride themselves on being current +**Default CTA:** Free report ("AI score" or similar) + +**Remix patterns:** +- "Standard listings are becoming invisible in your neighborhood." +- "Your home isn't showing up in AI search. Here's why." +- "Buyers ask AI first. Does it know about your home?" +- "The new search engine doesn't run on Google." + +**Gold-highlight rule:** Highlight "becoming invisible" / "AI search" / "doesn't run on Google" + +--- + +## 5. ANTI-ZESTIMATE (anti-algorithm) + +**Past hook:** "Your Zestimate Is Wrong" +**Lever:** "What you've been told isn't true" +**Best for:** Pairs well with equity angle; owners checking Zillow regularly +**Default CTA:** Home valuation + +**Remix patterns:** +- "Zillow doesn't know about your remodel." +- "The Zestimate is off by $___ on most EPA homes." *(verify before print)* +- "Algorithms guess. I measure." +- "Your home is worth more than the bot says." + +**Gold-highlight rule:** Highlight "WRONG" / "more than the bot says" / the dollar gap + +--- + +## 6. NEIGHBOR ENVY (curiosity + social proof) + +**Past hook:** "What Did The Last 5 Homes On Your Street Really Sell For?" +**Lever:** "Your neighbors know something you don't" +**Best for:** Any audience — works on farm + past clients. Strongest archetype for dual-purpose cards. +**Default CTA:** Home valuation or free report + +**Remix patterns:** +- "[Street] just had a record sale. Want to see it?" +- "Three homes on your block sold this spring." +- "Your neighbor cashed out $___ above asking." *(verify before print)* +- "The numbers on your block this year." + +**Gold-highlight rule:** Highlight "5 HOMES" / "REALLY" / "$___ above asking" + +--- + +## How to remix (don't just copy) + +When a user picks an archetype: +1. Pull the **lever** (curiosity / scarcity / pride / FOMO). +2. Look at **recent angles** below to avoid repetition within 3-card window. +3. Generate 2-3 fresh variations using the patterns above. +4. Always ask Graeham which one to use. + +## Layout principles (every card) + +- **One hook per card.** No combo headlines. +- **3-second rule.** The headline must register at glance distance. +- **Curiosity loop.** The front should pose a question / claim that's painful to leave unanswered. The back delivers the payoff + CTA. +- **Gold-highlight on 1-3 words max.** More than 3 dilutes attention. +- **The flip prompt** ("Flip over for…") is mandatory. + +--- + +## Cards shipped (memory — append as we go) + +| Date | Archetype | Headline | CTA | +|---|---|---|---| +| 03/01/25 | Equity | Do You Know How Much Equity You've Built This Year? | Free equity report | +| 03/15/25 | Buyer-tagged | I Already Have Your Buyer TAGGED | Call/text | +| 04/15/25 | Anti-Zillow buyer pool | I Have A List Of Buyers Who Don't Use Zillow | Scan QR | +| 05/01/26 | AI search invisibility | Why Isn't ChatGPT Recommending Your Home? | Property AI score | +| 05/15/26 | Anti-Zestimate | Your Zestimate Is Wrong | Precision equity audit | +| 06/01/26 | Neighbor envy | What Did The Last 5 Homes On Your Street Really Sell For? | Free home valuation | + +**Repetition rule:** Don't reuse the same archetype within 3 cards. diff --git a/skills/farming-postcard/references/option-cache.md b/skills/farming-postcard/references/option-cache.md new file mode 100755 index 0000000..d256135 --- /dev/null +++ b/skills/farming-postcard/references/option-cache.md @@ -0,0 +1,132 @@ +# Option Cache — Scheduled Preview Options + +This file stores the postcard hook options that the skill emails to Graeham 7 days before each drop date. When Graeham comes back to Cowork and says "pull up what you emailed me", read this file, find the most recent entry with `Status: pending pick`, and present those options for selection. + +After he picks, mark his pick `PICKED` and the others `not picked`, then move the whole entry under the **Resolved** section at the bottom. + +--- + +## Pending picks + +## 2026-06-15 (emailed 2026-05-27 — TEST RUN) +Status: pending pick + +### Option 1 — Buyer-tagged +Front headline: I have 3 buyers looking for your BLOCK. +Back headline: Active buyers · not just window-shoppers +Back body: While other agents wait for the phone to ring, my geofencing is tracking three pre-approved buyers actively touring properties on streets around you. Your home might be the one they're waiting for. +CTA line: Call me · let's see if you match +CTA type: Call/text Graeham +QR target URL: tel:+16503084727 +Why this works: Concrete number (3) + proximity ("your block") = strongest scarcity hook for cold farm audience +Audience fit: Farm +⚠ Verification needed: Confirm "3 buyers" is real before print; soften to "buyers" if not + +### Option 2 — Anti-Zillow buyer pool +Front headline: Zillow shows everyone. I show the ones who actually buy. +Back headline: Off-market buyers don't browse — they get TAGGED +Back body: Most listings reach the same tired buyer pool. My system identifies high-intent, pre-qualified buyers who don't waste agent time scrolling Zillow. They're looking for homes like yours right now. +CTA line: Free off-market match check +CTA type: Off-market buyers +QR target URL: [NOT SET — needs URL] +Why this works: Differentiates from every other agent doing standard listings + creates exclusivity +Audience fit: Both + +### Option 3 — Equity refresh +Front headline: Your home grew $___ this year. Want the real number? +Back headline: Stop checking ZILLOW · get the real number +Back body: Zillow's algorithm hasn't seen your kitchen remodel, your roof work, or the comp that sold three doors down last month. The number it's showing you is wrong — and probably low. Get the real one. +CTA line: Free precision equity report +CTA type: Home valuation +QR target URL: [NOT SET — needs URL] +Why this works: Pride + curiosity + tangible payoff. Equity archetype hasn't run since 03/01/25 — feels fresh +Audience fit: Both +⚠ Verification needed: Fill $___ blank with real EPA appreciation number before print + +### Option 4 — WILDCARD · Live market activity +Front headline: 11 offers. 6 days. Same zip code as you. +Back headline: This is what the EPA market looks like RIGHT NOW +Back body: If you've been waiting for the "right time" to sell, this is your signal. A home in your zip code just closed at 11 offers in under a week. Your home would compete in the same environment. +CTA line: Free market timing check +CTA type: Free report (market) +QR target URL: [NOT SET — needs URL] +Why this works: Hyper-local social proof + urgency. New archetype — if it lands, add as #7 to library. +Audience fit: Both +⚠ Verification needed: Requires real recent EPA multi-offer comp — do not ship without it + +--- + +## Format reference (for the cron job to follow) + +When Workflow B emails options, append an entry like this: + +```markdown +## [TARGET_MAIL_DATE] (emailed [SENT_DATE]) +Status: pending pick + +### Option 1 — [Archetype name] +Front headline: [Plain text with markup] +Back headline: [Plain text] +Back body: [3-sentence italic body] +CTA line: [Gold CTA tagline] +CTA type: [home valuation / testimonials / free report / etc.] +QR target URL: [URL from cta-router.md] +Why this works: [One-line rationale] +Audience fit: [Farm / Past clients / Both] + +### Option 2 — [Archetype name] +... + +### Option 3 — [Archetype name] +... +``` + +--- + +## Resolved + +*(Picked options move here after the user selects one. Keeps a permanent record of what was offered + what was chosen for pattern analysis over time.)* + +## 2026-06-15 (emailed 2026-05-27 — FULL PIPELINE TEST, fresh remixes) +Status: pending pick (test run — supersedes prior June 15 entry) + +### Option 1 — Buyer-tagged (FRESH REMIX) +Front headline: Your home is on someone's list. They just haven't seen it yet. +Back headline: I have buyers looking for your street — and they're pre-approved +Back body: When buyers tour properties on your block, my system tags them. Some have been waiting months for a home like yours to come available. Want me to see if you match? +CTA line: 5-minute match check · free +CTA type: Call/text Graeham +QR target URL: tel:+16503084727 +Why this works: No number to fabricate. "Someone's list" creates inevitability. +Audience fit: Farm + +### Option 2 — Anti-Zillow buyer pool (FRESH REMIX) +Front headline: The buyers Zillow can't show you. +Back headline: Off-market buyers don't browse — they get TAGGED +Back body: Public portals show every casual scroller. My pipeline is the buyers who've already proven they'll close — pre-approved, agent-vetted, ready. +CTA line: Free off-market match check +CTA type: Off-market buyers +QR target URL: [NOT SET] +Why this works: 6-word headline. Reframes Zillow's audience as a feature for the seller, not against. +Audience fit: Both + +### Option 3 — Equity refresh (FRESH REMIX) +Front headline: You're sitting on more than you think. +Back headline: The equity check most owners never get +Back body: Zillow gives you a zip-code average. Your bank gives you last year's appraisal. Neither sees your remodel, your block's recent sales, or what your home would actually trade for today. +CTA line: Free precision equity report +CTA type: Home valuation +QR target URL: [NOT SET] +Why this works: No dollar amount to fabricate. Positive frame pulls pride + curiosity without market anxiety. +Audience fit: Both + +### Option 4 — WILDCARD · Value Gap (loss aversion angle) +Front headline: Most EPA owners are undervaluing their home. +Back headline: Find out where YOUR home really sits +Back body: The gap between what Zillow shows and what a home actually trades for is often double-digit percent. If you're sitting on six figures of unrecognized equity, you should at least know it. +CTA line: See where you really stand +CTA type: Home valuation +QR target URL: [NOT SET] +Why this works: Loss aversion is stronger than gain. New sub-angle — if it lands, add as 7th archetype to library. +Audience fit: Both +⚠ Verification needed: Need a real local EPA gap stat before print diff --git a/skills/farming-postcard/references/print-specs.md b/skills/farming-postcard/references/print-specs.md new file mode 100755 index 0000000..9c533fd --- /dev/null +++ b/skills/farming-postcard/references/print-specs.md @@ -0,0 +1,91 @@ +# Print Specs — Universal Mail Works Defaults + +⚠️ **VERIFY BEFORE FIRST PRINT RUN** — UMW's exact spec sheet isn't locked in this skill. These are industry-standard 6×4 postcard defaults that should work for most vendors but should be confirmed with UMW before first print. + +## Default specs + +| Spec | Value | Notes | +|---|---|---| +| Trim size | 6" × 4" | Standard landscape postcard | +| Bleed | 0.125" each side | Total canvas: 6.25" × 4.25" | +| Safe zone | 0.25" from trim edge | Keep type/important elements inside this | +| Resolution | 300 DPI | For any raster images (headshots) | +| Color mode | CMYK | RGB will color-shift on press | +| File format | PDF/X-1a preferred, PDF/X-4 acceptable | Print-ready PDF standards | +| Fonts | Embedded or outlined | Outline to be safe (no font substitution risk) | + +## PDF render pipeline + +Step 1: Render HTML to PDF at 300 DPI using headless browser: + +```bash +# Install once +pip install playwright --break-system-packages --quiet +python -m playwright install chromium + +# Render +python -c " +from playwright.sync_api import sync_playwright +import sys +html_path = sys.argv[1] +pdf_path = sys.argv[2] +with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(f'file://{html_path}') + page.pdf(path=pdf_path, width='6.25in', height='4.25in', print_background=True, margin={'top':'0','bottom':'0','left':'0','right':'0'}) + browser.close() +" "[HTML_PATH]" "[PDF_PATH]" +``` + +Step 2: For production-grade CMYK conversion, the print shop will typically handle this. Optionally pre-convert using Ghostscript: + +```bash +gs -dSAFER -dBATCH -dNOPAUSE -dNOCACHE -sDEVICE=pdfwrite \ + -sColorConversionStrategy=CMYK \ + -dProcessColorModel=/DeviceCMYK \ + -sOutputFile=output_cmyk.pdf input.pdf +``` + +## CMYK approximations of gold (`#C2A14E`) + +If the printer asks for CMYK specifically: +- Coated stock: C:25 M:35 Y:75 K:5 +- Uncoated stock: C:20 M:30 Y:70 K:0 + +These shift slightly between presses. If color match matters, ask UMW for a press proof on the first run. + +## Vendor-specific notes + +### Universal Mail Works (default) +- Specs to confirm: trim size options, bleed requirement, file format preference, EDDM eligibility +- Once confirmed, update this file with their official spec sheet URL/values +- Status: ⚠️ NOT YET CONFIRMED + +### Wise Pelican (backup vendor) +- 6×4.25 standard (slightly taller than UMW default) +- 0.125" bleed +- PDF/X-1a or X-4 +- Spec sheet: https://www.wisepelican.com/sizes-and-specifications + +### Corefact (jumbo option) +- 6×9 jumbo postcards +- Triggers a different layout system entirely — current template is locked to 6×4 proportions + +## EDDM (Every Door Direct Mail) + +- Minimum size: 6.125" × 4.25" (UMW default fits) +- Must include EDDM indicia on the address panel +- Discounted postage rate (~$0.20/piece vs ~$0.34 First Class) +- EDDM doesn't allow targeted lists — entire postal routes only + +## Pre-print checklist + +Before sending any card to UMW for the first run: +- [ ] Verify UMW exact spec sheet (trim, bleed, resolution, format) +- [ ] Confirm CMYK color match with press proof if budget allows +- [ ] QR code tested with at least 3 phone cameras at arm's length +- [ ] Address panel + indicia placement confirmed with UMW +- [ ] Disclaimer text legible at print scale +- [ ] Phone number + URL spelled correctly (review twice) +- [ ] Graeham's name spelled correctly diff --git a/skills/farming-postcard/references/schedule-log.md b/skills/farming-postcard/references/schedule-log.md new file mode 100755 index 0000000..059f9b6 --- /dev/null +++ b/skills/farming-postcard/references/schedule-log.md @@ -0,0 +1,32 @@ +# Schedule Log — Cron Run History + +Each time Workflow B (scheduled preview) fires, append one line here: + +``` +[YYYY-MM-DD HH:MM] [run_type] target=[mail_date] options=[N] email_status=[draft_created|sent|failed] +``` + +`run_type` values: `15th-preview` (fires 8th of month) or `1st-preview` (fires 24th of month). + +--- + +## Runs + +[2026-05-27 14:00] TEST RUN (manual fire, draft only) target=2026-06-15 options=4 email_status=draft_created + - Archetypes: Buyer-tagged, Anti-Zillow buyer pool, Equity refresh, WILDCARD live market activity + - Triggered to validate pipeline before June 8 cron + - Gmail draft created — not sent (no SMTP credential yet) + +[2026-05-27 23:55] REAL SEND TEST (SMTP, on-brand v3 template) target=2026-06-15 options=4 email_status=sent + - Recipients: graehamwatts@gmail.com + graehamwattsvideo@gmail.com + - Subject: [ON-BRAND v3 TEST] Postcard options for June 15 — system check from Cowork + - SMTP via smtp.gmail.com:465, authenticated with App Password + - Pipeline confirmed working + +[2026-05-27 23:59] FULL PIPELINE TEST (SMTP, fresh remixed headlines) target=2026-06-15 options=4 email_status=sent + - Recipients: graehamwatts@gmail.com + graehamwattsvideo@gmail.com + - Subject: [TEST EMAIL] Postcard options for June 15 — full pipeline test + - Archetypes: Buyer-tagged (NEW remix), Anti-Zillow buyer pool (NEW remix), Equity refresh (NEW remix), WILDCARD Value Gap (new sub-angle) + - Demonstrates skill generates FRESH headlines vs. repeating prior options + - Same archetype slate as prior test, completely different headline copy — validates remix patterns work + - Pipeline confirmed end-to-end. June 8 cron will fire cleanly without intervention. diff --git a/skills/farming-postcard/scripts/send_options_email.py b/skills/farming-postcard/scripts/send_options_email.py new file mode 100755 index 0000000..1e0014c --- /dev/null +++ b/skills/farming-postcard/scripts/send_options_email.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Send the postcard options preview email via Gmail SMTP. + +Reads the Gmail App Password from C:\\Users\\Graeham Watts\\Documents\\Claude\\Skills\\gmail-app-password.txt +Sends FROM graehamwatts@gmail.com TO both Graeham + Peter. + +Usage: + python send_options_email.py "" + +Or from Python: + from send_options_email import send_options_email + send_options_email(html_body, subject, plaintext_body) +""" +import os +import sys +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from pathlib import Path + +# Locked recipients (matches design-tokens.md continuity rule) +SENDER = "graehamwatts@gmail.com" +RECIPIENTS = ["graehamwatts@gmail.com", "graehamwattsvideo@gmail.com"] # Graeham + Peter + +# Credential path — same folder as github-token.txt +APP_PASSWORD_FILE = Path(r"C:\Users\Graeham Watts\Documents\Claude\Skills\gmail-app-password.txt") + +# Cross-platform fallback (when run from the cowork bash mount) +APP_PASSWORD_FILE_LINUX = Path("/sessions/inspiring-awesome-hawking/mnt/Skills/gmail-app-password.txt") + + +def load_app_password(): + """Load Gmail App Password from credential file. Returns None if not found.""" + for path in (APP_PASSWORD_FILE, APP_PASSWORD_FILE_LINUX): + if path.exists(): + pwd = path.read_text().strip().replace(" ", "") + if pwd and pwd != "PASTE_YOUR_GMAIL_APP_PASSWORD_HERE": + return pwd + return None + + +def send_options_email(html_body: str, subject: str, plaintext_body: str = ""): + """Send the options preview email to both Graeham + Peter via Gmail SMTP.""" + app_password = load_app_password() + if not app_password: + raise RuntimeError( + f"Gmail App Password not found. Expected at: {APP_PASSWORD_FILE}\n" + f"Generate one at https://myaccount.google.com/apppasswords and save to that file." + ) + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = f"Graeham Watts <{SENDER}>" + msg["To"] = ", ".join(RECIPIENTS) + + if plaintext_body: + msg.attach(MIMEText(plaintext_body, "plain")) + msg.attach(MIMEText(html_body, "html")) + + with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: + server.login(SENDER, app_password) + server.sendmail(SENDER, RECIPIENTS, msg.as_string()) + + return {"sent_to": RECIPIENTS, "subject": subject} + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: send_options_email.py '' []") + sys.exit(1) + + html_path = sys.argv[1] + subject = sys.argv[2] + plaintext_path = sys.argv[3] if len(sys.argv) > 3 else None + + html_body = Path(html_path).read_text(encoding="utf-8") + plaintext_body = Path(plaintext_path).read_text(encoding="utf-8") if plaintext_path else "" + + result = send_options_email(html_body, subject, plaintext_body) + print(f"SENT to {result['sent_to']}: {result['subject']}") diff --git a/skills/farming-postcard/templates/option-card.html b/skills/farming-postcard/templates/option-card.html new file mode 100755 index 0000000..b759a3d --- /dev/null +++ b/skills/farming-postcard/templates/option-card.html @@ -0,0 +1,38 @@ + +
+ + + +
Option {{OPTION_NUMBER}} · {{ARCHETYPE_NAME}}Lever: {{LEVER}}
+
{{FRONT_HEADLINE_HTML}}
+
{{VERIFICATION_LINE}}
+
+
Back headline
+
{{BACK_HEADLINE_HTML}}
+
Back body
+
{{BACK_BODY}}
+
CTA: {{CTA_LINE}}
+ + + +
CTA type: {{CTA_TYPE}}Audience: {{AUDIENCE}}
+
Why this works: {{WHY_THIS_WORKS}}
+
+
diff --git a/skills/farming-postcard/templates/options-email-template.html b/skills/farming-postcard/templates/options-email-template.html new file mode 100755 index 0000000..94f4329 --- /dev/null +++ b/skills/farming-postcard/templates/options-email-template.html @@ -0,0 +1,61 @@ + + + + + +
+ + + + + + +
  + +
Farming Postcard Options · Auto-Generated
+
Postcard options
for {{TARGET_MAIL_DATE}}
+
+
Hooks for your {{TARGET_MAIL_DATE_SHORT}} farming postcard. Pick by {{PICK_DEADLINE}} so Peter has 3 business days to finalize. Reply with the option number, or open Cowork and say "use option [N] for the {{TARGET_MAIL_DATE_SHORT}} postcard."
+ + + + + +
Last 3 Shipped
{{LAST_3_SHIPPED}}
Cadence
{{CADENCE_LINE}}
Pick Deadline
{{PICK_DEADLINE}}
+ + {{OPTIONS_HTML}} + +
+
How to pick
+
Option A: Reply with the option number ("go with 2").
Option B: Open Cowork: "use option [N] for the {{TARGET_MAIL_DATE_SHORT}} postcard."
Deadline: {{PICK_DEADLINE}}.
+ +
+ +
+ + + +
+
INTERO
+
A Berkshire Hathaway Affiliate
+
GRAEHAM WATTS
+
REALTOR® · The Martin Team
DRE #01466876
650-308-4727
graehamwatts@gmail.com
www.graehamwatts.com
+
Auto-generated by the farming-postcard skill on {{GENERATED_DATE}}.
Live archive: graehamwatts.github.io/online-content/farming-postcards
+
+ +
+ +
+ diff --git a/skills/farming-postcard/templates/postcard-template.html b/skills/farming-postcard/templates/postcard-template.html new file mode 100755 index 0000000..5c4d655 --- /dev/null +++ b/skills/farming-postcard/templates/postcard-template.html @@ -0,0 +1,256 @@ + + + + +Graeham Watts Farming Postcard — {{MAIL_DATE}} + + + + + + + + +
+

Farming Postcard — {{MAIL_DATE}}

+

Archetype: {{ARCHETYPE}}

+
+ +
FRONT
+
+
+
+ {{FRONT_HEADLINE_HTML}} +
+
+
{{FRONT_SUBLINE_HTML}}
+ + + + +
+
+
GRAEHAM
HEADSHOT
(pointing)
+
+ +
+
REALTOR®
+
The Martin Team
+
DRE #01466876
+
650-308-4727
+
graehamwatts@gmail.com
+
www.graehamwatts.com
+
+
+
+
+ +
BACK
+
+
+
+
{{BACK_HEADLINE_HTML}}
+
{{BACK_BODY_HTML}}
+
{{BACK_CTA_LINE}}
+
+
+ + + + + + + + + + + + + + + + +
+
+
{{QR_SCAN_LABEL}}
+
SCAN ME
+
+
+
+
GRAEHAM
HEADSHOT
(smiling)
+
+ +
+
REALTOR®
+
The Martin Team
+
DRE #01466876
+
+
+
650-308-4727
+
graehamwatts@gmail.com
+
www.graehamwatts.com
+
+
+
+ If your home is listed with another broker, please disregard this postcard. Homes not necessarily sold by this broker. +
+
+ + + diff --git a/skills/ghl-crm-audit/SKILL.md b/skills/ghl-crm-audit/SKILL.md new file mode 100644 index 0000000..3ea422a --- /dev/null +++ b/skills/ghl-crm-audit/SKILL.md @@ -0,0 +1,509 @@ +--- +name: ghl-crm-audit +description: "GoHighLevel CRM Audit Agent. Use ANY time user mentions: GHL, GoHighLevel, CRM audit, contact audit, lead audit, CRM cleanup, neglected contacts, stale leads, pipeline audit, follow-up gaps, CRM health check, lead nurture audit, CRM report, contact follow-up, overdue tasks in CRM, missed follow-ups, pipeline mismatch, pipeline health score, or anything related to auditing, cleaning up, or automating actions in a GoHighLevel CRM account. Also trigger when user wants to pull contact data from GHL, flag neglected leads, create follow-up tasks in GHL, enroll contacts in workflows, or run a daily CRM health report." +--- + +# GoHighLevel CRM Audit Agent + +You are a GoHighLevel CRM Audit Agent. Your job is to guide the user through a multi-phase process: connect to their GHL account, audit every contact, present a prioritized report with behavioral bucketing and pipeline mismatch detection, execute cleanup actions, and optionally automate the audit on a schedule via GitHub Action. + +**Before starting any phase, read the reference file:** +- `references/flag-criteria.md` — Defines the 5-bucket behavioral system, Critical/Warning/Watch flag overlay, priority scoring formula, Pipeline Health Score calculation, Pipeline Mismatch Detection rules, and report structure + +**Single source of truth for GHL integration paths:** `../shared-references/integrations.md` (sections 12 GoHighLevel and "Windsor MCP + Direct API Parallel-Pull Rule"). When this skill's Phase 1 and that doc disagree, that doc wins — update this one to match. + +--- + +## How This Works + +The user (typically a real estate agent or business owner) has a GoHighLevel CRM full of contacts. Over time, some contacts get neglected — no follow-up, no notes, no tasks, sitting in no pipeline. This skill audits every contact, assigns them to behavioral buckets (HOT / WARM / FOLLOW UP / LONG TERM / DEAD), overlays urgency flags (Critical / Warning / Watch), detects mismatches between pipeline stages and actual behavior, generates a prioritized "Today's Top 10" action list, produces a Pipeline Health Score, creates Adrian's Task List for the coordinator, and lets the user execute fixes directly through the GHL API. + +The process has four phases: +1. **Connect** — Authenticate to GHL via the PIT direct path (primary) or Windsor fallback (parallel) +2. **Audit** — Pull all contacts and their associated data, apply the bucket + flag system, detect pipeline mismatches, generate the full report +3. **Execute** — Take action on flagged contacts (enroll in workflows, create tasks, add notes, move pipeline stages, send SMS) +4. **Automate** — Schedule the audit via a GitHub Action that runs on cron and emails the report + +Work through each phase step by step. At the start of each phase, tell the user exactly what you need from them before proceeding. Do not skip ahead. Confirm completion of each step before moving to the next. + +If you hit any errors, explain what went wrong in plain English, what likely caused it, and what to try next. Never stop silently. + +--- + +## Phase 1: Connect to GoHighLevel + +> **Direction (May 2026):** The primary connection path is the **GoHighLevel Private Integration Token (PIT)** hitting `services.leadconnectorhq.com` directly. Windsor's `gohighlevel` connector is the **backup / parallel-pull** alternative per the canonical Parallel-Pull Rule in `shared-references/integrations.md`. n8n is **no longer used** as a GHL integration path — that approach was retired May 12, 2026 in favor of the direct PIT + GitHub Action pattern. + +There are three methods, in priority order. Always try Method A first. Methods B and C exist for redundancy. + +### Method A — Direct PIT (PRIMARY) + +GoHighLevel issues Private Integration Tokens that authenticate against `services.leadconnectorhq.com` directly. This is location-scoped, doesn't depend on third-party brokers, and is the same path used by `pipeline-dashboard` (which has verified it working — pulled 4,027 contacts, 2,891 opportunities, all 7 pipelines in a prior session). + +**Credentials lookup order:** + +1. Read `C:\Users\Graeham Watts\Documents\Claude\Skills\ghl-pit.txt` (gitignored) + - Line 1: PIT token (starts `pit-`) + - Line 2: Location ID (`6wuU3haUH7uNeT20E3UZ`) +2. If file is missing, guide the user to create a fresh PIT (steps below). + +**Test call format:** + +``` +GET https://services.leadconnectorhq.com/opportunities/pipelines?locationId={LOCATION_ID} +Authorization: Bearer {PIT} +Version: 2021-07-28 +Accept: application/json +``` + +A successful response returns `{ "pipelines": [...] }` with 7 pipelines for Graeham's location. + +**If the PIT file is missing or the call returns 401**, guide the user through generating a new one: + +"I need a GoHighLevel Private Integration Token to connect directly. Here's exactly what to do: + +1. Log into GoHighLevel +2. Go to Settings (bottom-left gear icon) +3. Click 'Private Integrations' in the left menu +4. Click 'Create New Integration' +5. Name it: `Claude Audit Agent` +6. Select these scopes: + - Contacts (Read + Write) + - Conversations (Read + Write) + - Opportunities (Read + Write) + - Workflows (Read) + - Calendars (Read) + - Payments (Read) + - Locations (Read) + - Tasks (Read + Write) + - Notes (Read + Write) + - Campaigns (Read) +7. Click Create and COPY the token (starts with `pit-`) +8. Also note your Location ID from Settings → Company → Locations + +Save the PIT to `C:\Users\Graeham Watts\Documents\Claude\Skills\ghl-pit.txt` — Line 1: PIT, Line 2: Location ID. The file is gitignored so it won't commit. + +This token doesn't expire unless you revoke it, so this is a one-time setup." + +After the user saves the token, retest the call. + +**Sandbox network note:** If running inside a Cowork sandbox (proxy-blocked from `services.leadconnectorhq.com`), the actual API calls must happen in a GitHub Action or on the user's local machine — not inside the sandbox. See Phase 4 for the GitHub Action pattern. + +### Method B — Windsor MCP (PARALLEL / BACKUP) + +Per the canonical Parallel-Pull Rule (`shared-references/integrations.md` lines 295-349), Windsor is the alt path when both are available. It can run in parallel with Method A to compare completeness, or as a standalone fallback when the PIT is unavailable. + +- **Connector:** `gohighlevel` +- **Account:** `6wuU3haUH7uNeT20E3UZ` +- **Use when:** PIT is missing/expired, or as a parallel pull to cross-validate completeness. +- **Known limitations:** Windsor's GHL connector cannot cross-reference `contact_source` with `pipeline_stage` in a single query. For Lead Lifecycle funnel analysis (which depends on this join), Method A is required. + +### Method C — Composio HighLevel Toolkit (TERTIARY) + +If both A and B are unavailable, Composio's `highlevel` toolkit provides another path. It requires creating an auth config in Composio's dashboard first — Composio doesn't auto-manage GHL OAuth. Only use this if explicitly directed; the PIT path is preferred for maintenance simplicity. + +### Decision Tree + +``` +1. Read ghl-pit.txt — token present and unexpired? + YES → use Method A (PIT direct) + NO → continue +2. Is Windsor `gohighlevel` connector reachable? + YES → use Method B (Windsor backup) + tell user to refresh the PIT for next run + NO → continue +3. Is Composio `highlevel` configured? + YES → use Method C + NO → stop and tell user no GHL connection is available +``` + +After a successful connection (any method), verify by pulling the total contact count, then confirm: + +"Connected successfully via [Method A / B / C]. Your GHL account has [X] total contacts. Ready to begin the audit. Type GO to start Phase 2." + +If connection fails on all three methods, walk the user through troubleshooting: check token scopes (Method A), verify Windsor connector authorization (Method B), check Composio dashboard (Method C). + +--- + +## Phase 2: Full CRM Audit + +When the user says GO, begin pulling ALL contacts and auditing every single one. + +### Data to pull for EVERY contact: +- Full name, phone, email, lead source tag, date added to CRM +- Last activity date (any touch: note, call, SMS, email, appointment) +- All notes: total count + date of most recent note + who wrote it +- All tasks: open count, completed count, any overdue (date overdue) +- Pipeline name + current stage name +- Workflow/drip campaign enrollment — name of workflow + enrollment date +- Conversation history: last inbound message date, last outbound message date +- Opportunity: yes/no, stage, dollar value if present +- Tags currently applied +- Appointment history: any booked, completed, or no-showed +- IDX/property search activity: property saves, search alerts, portal logins (if available via GHL custom fields or tags) +- DND (Do Not Disturb) status + +### Step 2A — Assign Behavioral Buckets + +Every contact gets assigned to exactly one behavioral bucket based on their recency of meaningful activity. See `references/flag-criteria.md` for the complete definitions. In summary: + +- **HOT (0-7 days)** — Active engagement within the last 7 days. Inbound messages, property saves, appointment bookings, form submissions, or direct replies. +- **WARM (8-30 days)** — Activity within 8-30 days. Still engaged but momentum is slowing. Opened emails, clicked links, responded to outreach, or had an appointment. +- **FOLLOW UP (31-90 days)** — Gone quiet for 31-90 days. No inbound activity but hasn't opted out. Needs re-engagement. +- **LONG TERM (91+ days)** — No meaningful activity in 91+ days. Still a valid contact but not actively in-market. +- **DEAD** — Contact has opted out (DND), bounced on all channels, or explicitly said they're not interested with no subsequent re-engagement. + +### Step 2B — Apply the Flag System (Overlay) + +The flag system works on top of the buckets to highlight urgency. Read `references/flag-criteria.md` for the complete flag definitions. In summary: + +- **CRITICAL (Red)** — Zero notes ever, zero contact attempts, not enrolled in any workflow, or last outbound was 10+ days ago with no response/follow-up. Also: HOT contact with no task assigned for next step, or contact with open opportunity and no follow-up in 5+ days. +- **WARNING (Yellow)** — Open task 3+ days overdue, no pipeline stage assigned, last outbound was 5-9 days ago with no follow-up, added 5+ days ago with no appointment ever booked, or WARM contact dropping toward FOLLOW UP with no re-engagement plan. +- **WATCH (Green)** — Added 2-4 days ago with no appointment yet, enrolled in workflow but no human outreach logged, has notes but no task assigned for next step, or LONG TERM contact showing early re-engagement signals. + +### Step 2C — Detect Pipeline Mismatches + +Compare each contact's behavioral bucket (based on actual activity) against their GHL pipeline stage. When these disagree, flag it as a Pipeline Mismatch. + +**What counts as a mismatch:** + +1. **Hot behavior, cold pipeline** — Contact shows HOT activity (inbound messages in last 7 days, property saves, appointment requests) but sits in a nurture/long-term/inactive pipeline stage. The GHL label is outdated. + +2. **Cold behavior, hot pipeline** — Contact is in an "Active Buyer," "Hot Lead," "Ready to Close," or similar active pipeline stage but has had no meaningful activity in 30+ days. The pipeline stage is aspirational, not actual. + +3. **No pipeline, active behavior** — Contact has recent activity but isn't assigned to any pipeline at all. They're falling through the cracks. + +4. **Dead behavior, active pipeline** — Contact is DND or has bounced on all channels but still sits in an active pipeline stage. The record needs cleanup. + +For each mismatch, generate a callout: + +- Hot behavior in cold pipeline: `⚡ PIPELINE MISMATCH — This contact is in your '[Pipeline Stage]' pipeline but shows HOT behavioral activity ([specific evidence: e.g., 3 IDX property saves, inbound message 2 days ago]). The GHL label may be outdated.` +- Cold behavior in hot pipeline: `⚠️ PIPELINE MISMATCH — This contact is in your '[Pipeline Stage]' pipeline but has had no activity in [X] days. Consider moving to FOLLOW UP.` +- No pipeline with activity: `⚡ PIPELINE MISMATCH — This contact has recent activity ([evidence]) but isn't assigned to any pipeline. They need to be placed.` +- Dead in active pipeline: `🚫 PIPELINE MISMATCH — This contact is DND/bounced but still in '[Pipeline Stage]'. Remove from active pipeline.` + +### Step 2D — Calculate Priority Scores + +Every contact gets a priority score (0-100) that determines their position in the Today's Top 10. See `references/flag-criteria.md` for the complete scoring formula. The score weights: + +- **Recency of last activity** (30 points max) — More recent = higher score +- **Opportunity value** (25 points max) — Higher dollar value = higher priority +- **Flag severity** (20 points max) — Critical > Warning > Watch +- **Pipeline mismatch** (15 points max) — Mismatched contacts get a boost because they need attention regardless of other factors +- **Engagement trajectory** (10 points max) — Moving from WARM→HOT gets a boost; WARM→FOLLOW UP gets flagged + +### Step 2E — Calculate Pipeline Health Score + +The Pipeline Health Score is a single number (0-100) that represents overall CRM hygiene. See `references/flag-criteria.md` for the complete calculation. In summary it factors in: + +- % of contacts with a pipeline stage assigned +- % of contacts with at least one note in last 30 days +- % of contacts with an active task or workflow +- % of pipeline stages that match behavioral buckets (inverse of mismatch rate) +- % of contacts with follow-up scheduled within appropriate timeframe for their bucket + +### Step 2F — Generate the Report + +Produce a multi-section report. The report uses branded formatting when output as HTML or PDF (see Output Specs below). + +**Section 1 — Executive Summary:** +- Total contacts audited +- Bucket breakdown: HOT / WARM / FOLLOW UP / LONG TERM / DEAD with counts and percentages +- Flag breakdown: Critical / Warning / Watch counts and percentages +- Pipeline Health Score (0-100) with letter grade (A: 90+, B: 80-89, C: 70-79, D: 60-69, F: <60) +- Pipeline Mismatch count: "[X] contacts have pipeline mismatches — their GHL stage doesn't match their actual activity" +- Top 3 most urgent issues across the whole CRM +- Estimated revenue risk (count of Critical contacts with open opportunities, total dollar value at risk) + +**Section 2 — Today's Top 10:** +The 10 highest-priority contacts by priority score. For each contact: +- Name, bucket (HOT/WARM/etc.), flag level (Critical/Warning/Watch) +- Pipeline stage (current) + mismatch callout if applicable (using the ⚡/⚠️/🚫 format from Step 2C) +- Why they're in the Top 10 (specific evidence: "Last outbound 12 days ago, open opportunity $850K, zero tasks assigned") +- Recommended action (specific: "Call today, reference their property search in Los Gatos") +- Draft message in Graeham's voice — warm, personal, direct, not salesy. Example: "Hey [First Name], I was thinking about you — wanted to check in and see how your search is going. Anything I can help with? Let me know. — Graeham" + +**Section 3 — Adrian's Task List:** +A coordinator-ready checklist that Adrian (the team coordinator) can execute without interpretation. Organized by priority: +1. Tasks for Today's Top 10 contacts (specific actions with names and details) +2. Tasks for remaining Critical contacts +3. Tasks for Warning contacts with overdue items +4. Pipeline cleanup tasks (mismatches to resolve, stages to update) +5. Data hygiene tasks (missing pipeline assignments, contacts with no tags, duplicates spotted) + +Each task is written as an imperative action: "Call John Smith at (408) 555-1234 — last contact 15 days ago, has $600K opportunity open. Reference his Los Gatos property saves." NOT vague like "Follow up with John." + +**Section 4 — Critical Contacts:** +List each one with: Name | Bucket | Days since last contact | Gaps identified | Pipeline mismatch (if any) | Recommended actions. Sort by longest neglected first. + +**Section 5 — Warning Contacts:** +List each with: Name | Bucket | Gap type | Pipeline mismatch (if any) | Recommended action. Sort by gap type (group similar issues together). + +**Section 6 — Watch List:** +List each with: Name | Bucket | Watch reason | Suggested next step. + +**Section 7 — Pipeline Mismatches:** +Dedicated section listing ALL contacts with pipeline mismatches, grouped by mismatch type: +1. Hot behavior / Cold pipeline (most urgent — these are leads you might lose) +2. No pipeline / Active behavior (falling through cracks) +3. Cold behavior / Hot pipeline (pipeline needs updating) +4. Dead behavior / Active pipeline (cleanup needed) + +For each, show: Name | Current Pipeline Stage | Behavioral Bucket | Evidence | Recommended Pipeline Action + +**Section 8 — Weekly Patterns:** +Analysis of trends across the CRM: +- Activity trend: Are contacts generally becoming more or less engaged week-over-week? +- Response rate patterns: What days/times get the best response rates? +- Pipeline velocity: How quickly are contacts moving through stages? Where are they getting stuck? +- Bucket migration: How many contacts moved between buckets this week? (e.g., "8 contacts dropped from WARM to FOLLOW UP this week") +- **Pipeline Mismatch Trend:** Total mismatches found, breakdown by type, comparison to previous audit if available. "12 pipeline mismatches detected — 5 are hot leads stuck in nurture pipelines, 3 have no pipeline at all, 4 are inactive contacts in active pipelines." +- Lead source performance: Which sources are producing HOT contacts vs DEAD contacts? +- Follow-up gaps: Average time between touches by bucket. Are HOT leads getting fast enough follow-up? + +**Section 9 — Full Contact Register:** +Every contact audited, in a sortable table format: +Name | Bucket | Flag | Pipeline Stage | Mismatch? | Last Activity | Days Silent | Open Opp $ | Priority Score | Recommended Action + +Contacts with pipeline mismatches should have a visible indicator (⚡/⚠️/🚫) in the Mismatch column. + +**Section 10 — Execution Menu:** +After delivering the full report, display the execution options menu (see Phase 3). + +--- + +## Phase 3: Segmented Execution + +After the report, present the execution menu: + +``` +EXECUTION OPTIONS +Type the number of what you want me to execute: +1 — Enroll Critical contacts into nurture workflow +2 — Create follow-up tasks for Critical contacts +3 — Add audit notes to Critical contacts +4 — Move Critical contacts into pipeline +5 — Enroll Warning contacts into workflow +6 — Create tasks for Warning contacts with overdue follow-ups +7 — Send re-engagement SMS to Warning contacts +8 — Tag Watch list contacts for review +9 — Fix pipeline mismatches (move contacts to correct stage) +10 — Execute ALL Critical actions (1+2+3+4) +11 — Execute ALL Warning actions (5+6) +12 — Execute ALL pipeline mismatch fixes (9) +13 — Full execution (everything) +Or type a contact's name to run individual actions on just that person. +``` + +### Before executing anything, confirm with the user: +- For workflow enrollment (1, 5): Ask for the exact workflow name, search GHL to confirm it exists +- For pipeline assignment (4, 9): Ask for pipeline name and stage, or use the recommended stage from the mismatch analysis +- For SMS (7): Show the message draft for approval before sending. Default: "Hi [First Name], just wanted to reach out personally and make sure you're taken care of. Is there anything I can help you with today? — Graeham" +- For task creation (2, 6): Ask who tasks should be assigned to (default: Adrian) and due date (24h or 48h) +- For pipeline mismatch fixes (9): Show each proposed move for approval: "[Contact Name]: Move from '[Current Stage]' → '[Recommended Stage]' based on [evidence]" + +### During execution: +- Work through contacts one by one +- After every 10 contacts, give a running status: "Completed 10/47 — 37 remaining" +- If any individual action fails, log it, skip it, and continue — do not stop +- At the end, give a full execution log: succeeded, failed, skipped — with names + +### After execution: +Report: "Execution complete. Here's what happened: [summary]. Here are the [X] contacts where actions failed and why: [list]. Do you want me to retry failed contacts or move to Phase 4 — setting up automation?" + +--- + +## Phase 4: Build Automation + +When the user is ready to automate, offer two options. **n8n is intentionally not on this list** — that path was retired May 12, 2026. GHL data pulls happen from environments that can reach `services.leadconnectorhq.com` directly (a GitHub Action, the user's local machine, or — when sandbox-allowed — Claude itself). + +### Option A — Cowork Scheduled Task (Recommended for simple cases) + +This uses Cowork's built-in Scheduled Task system to run the audit on Claude itself. Best for: weekly internal reports where you want Claude to invoke this skill on a schedule and present results. + +- **Schedule:** Every Monday at 7:00 AM Pacific (or user's choice) +- **Connection:** PIT direct via `ghl-pit.txt` (Method A from Phase 1). Falls back to Windsor (Method B) automatically per the Parallel-Pull Rule. +- **Network constraint:** If running inside Cowork sandbox, the sandbox proxy blocks `services.leadconnectorhq.com`. The scheduled task must EITHER (a) fire a GitHub Action via `workflow_dispatch` (Option B below) and wait for the result, OR (b) instruct the user to run the audit on their local machine outside the sandbox. + +Tell the user: + +"I can set up a scheduled task that runs this audit automatically every Monday morning at 7 AM. Because Cowork's sandbox can't reach GoHighLevel directly, the scheduled task will trigger a GitHub Action that does the actual GHL pull on GitHub's network, then I'll present the audit results when you start your week. + +Want me to set that up? I can also adjust the day/time if Monday mornings don't work for you." + +Create the scheduled task with the full audit prompt including all bucket/flag/mismatch logic. The scheduled task SKILL.md should reference this skill's audit phases by name (e.g., "run ghl-crm-audit Phase 2 against the PIT in ghl-pit.txt"). + +### Option B — GitHub Action (Recommended for full automation + email delivery) + +A GitHub Action runs Python that pulls GHL data via the PIT, applies the audit logic, generates an HTML report, commits it to `Graehamwatts/online-content/dashboards/crm-audit/`, and emails it. This is the same pattern used by `pipeline-dashboard`. + +**Setup steps:** + +1. **Add secrets to `Graehamwatts/online-content` repo:** + - `GHL_PIT` — the Private Integration Token from `ghl-pit.txt` + - `GHL_LOCATION_ID` — `6wuU3haUH7uNeT20E3UZ` + - `GH_DASHBOARD_PAT` — fine-grained PAT scoped to `contents:write` + `actions:write` + - `GMAIL_*` (or SendGrid / SES creds) — for email delivery + - `TWILIO_*` (optional) — if SMS alerts on Critical contacts are wanted + +2. **Workflow file:** `.github/workflows/ghl-crm-audit.yml` in `Graehamwatts/online-content`. Should: + - Trigger on `schedule` (cron) AND on `workflow_dispatch` (so the scheduled task or a button can fire it manually) + - Check out the repo + - Run `scripts/ghl_audit.py` (which lives in this skill's `scripts/` folder and gets copied or symlinked into the repo) + - The script reads `GHL_PIT` and `GHL_LOCATION_ID` from env, pulls all contacts + opportunities + pipelines + notes + tasks, applies the audit logic from `references/flag-criteria.md`, generates the HTML report + - Commit the report to `dashboards/crm-audit/{{YYYY-MM-DD}}.html` + - Email the report with subject: `Weekly CRM Audit — [DATE] — [X] Critical | [Y] Warning | [Z] Mismatches | Health: [Score]/100` + - If Critical contacts exist AND SMS alerts opted in — send Twilio SMS alert + - On GHL API timeout — retry 3 times with 30s delays before failing the workflow run + +3. **Ask the user:** + - "What email address should the audit report go to?" + - "Do you want SMS alerts for Critical contacts? If yes, phone number?" + - "What schedule — weekly Monday 7am PT, or daily?" + - "Auto-execute any actions (e.g., always enroll new contacts with no workflow), or report-only?" + +4. **Generate the workflow YAML and the Python script** based on the user's answers. Push both to `Graehamwatts/online-content`. Test by manually firing `workflow_dispatch` once and verifying the report lands. + +This pattern is preferred over Option A when the user wants email delivery, SMS alerts, or fully autonomous runs that don't depend on Cowork being open. + +--- + +## Phase 5: Quality Control Verification (MANDATORY) + +**This step is not optional.** Before delivering any audit report or executing any actions, you MUST run a full verification pass. A CRM audit that misflags contacts or recommends wrong actions can cause real damage — missed follow-ups on hot leads, unnecessary contact with people already being handled, or embarrassment when the user acts on bad data. + +### The Verification Process + +After generating the audit report, perform a distinct second pass to check every section. Do NOT just re-read what you wrote — go back to the source data and verify against it. + +### What the Verification Checks + +**1. Bucket Assignment Accuracy** +- Re-check at least 10 contacts across different buckets. Does each one actually belong in the assigned bucket based on the thresholds in `references/flag-criteria.md`? +- Watch for these common errors: + - **Workflow-only activity**: Automated workflow emails don't count as "meaningful activity" for bucket assignment. A contact with only automated touches and no human interaction or inbound engagement should not be in HOT or WARM. + - **Timezone confusion**: GHL stores timestamps in UTC. Convert to user's timezone before calculating days since last activity. + - **Recently added**: A contact added 1 day ago with no notes shouldn't be Critical. Respect the time thresholds. + +**2. Flag Accuracy** +- Re-check every CRITICAL contact against the flag criteria in `references/flag-criteria.md`. Does each one actually meet at least one CRITICAL condition? +- Spot-check at least 5 WARNING contacts the same way. +- Watch for these common errors: + - **False Critical**: Contact was flagged Critical for "no outbound in 10+ days" but actually has a recent workflow-triggered email that counts as outbound + - **Missed Critical**: Contact has zero notes AND zero outbound but was only flagged Warning + - **Wrong date math**: "Last contact 15 days ago" but the date was actually 8 days ago + +**3. Pipeline Mismatch Accuracy** +- For every mismatch flagged, verify both sides: confirm the GHL pipeline stage AND confirm the behavioral evidence. +- Watch for: + - **False mismatch**: Contact is in "Active Buyer" pipeline AND has recent activity — that's not a mismatch, that's correct. + - **Missing mismatch**: Contact in "Long Term Nurture" just booked an appointment yesterday — that IS a mismatch that should be caught. + - **Stage name variations**: "Hot Leads" vs "Hot Lead" vs "Active - Hot" — map these correctly to behavioral expectations. + +**4. Data Completeness** +- Verify the total contact count in the Executive Summary matches the actual number pulled from GHL +- Check that bucket totals sum to the total contacts audited (no contacts dropped or double-counted) +- Check that the mismatch count in the Executive Summary matches the count in the Pipeline Mismatches section +- If any contacts failed to load or had API errors, list them as "Unable to Audit" — not silently excluded + +**5. Revenue Risk Accuracy** +- For every Critical contact with a listed opportunity value, verify the opportunity actually exists and the dollar amount is correct +- Sum the revenue risk totals and verify the Executive Summary number matches +- Don't count closed/won or closed/lost opportunities — only open ones + +**6. Priority Score Verification** +- Re-calculate priority scores for the Top 10 to make sure the ranking is correct +- Verify no contact outside the Top 10 should actually be in it + +**7. Adrian's Task List Accuracy** +- Every task must correspond to a specific flagged contact with a specific action +- Verify no tasks are listed for contacts that are actually healthy +- Check that recommended actions match the flag type and bucket +- Ensure pipeline mismatch fixes are included in the task list + +**8. Execution Safety Check (before any Phase 3 actions)** +- Before executing any write action, verify the target contact is correct (name + ID match) +- Before sending any SMS, double-check the phone number belongs to the intended contact +- Before enrolling in a workflow, verify the workflow exists and is active in GHL +- Before moving pipeline stages, confirm the target pipeline and stage exist +- If batch-executing on 10+ contacts, re-verify the list against the report + +**9. Tone and Clarity Check** +- Scan the report for vague language ("some contacts may need attention") — replace with specifics +- Make sure every recommendation has a concrete action, not just an observation +- Check that the report doesn't editorialize about the quality of the user's CRM management +- Verify draft messages sound like Graeham (warm, personal, direct) not generic + +### Verification Output + +Fix any errors found during verification. If a contact's bucket or flag level changed, update the report and mention the correction to the user. If the pipeline health score or mismatch count changed, mention that too. + +**Only deliver the report after verification is complete.** + +### Common Pitfalls + +- **Timezone confusion**: GHL stores timestamps in UTC. Make sure you're converting to the user's timezone before calculating "days since last contact." Getting this wrong can flag contacts as Critical when they were contacted yesterday. +- **Workflow vs human contact**: An automated workflow email counts as "outbound" but it's not the same as a human follow-up. The audit should distinguish between automated touches and manual outreach. A contact with 10 automated emails but zero manual notes is still a concern. +- **Closed opportunities**: Don't include closed/won or closed/lost deals in the revenue risk calculation. Only open, active opportunities count. +- **Duplicate contacts**: GHL often has duplicate contacts (same person, different records). If you spot obvious duplicates (same name + phone or same name + email), mention it but flag them separately — don't merge or skip without the user's approval. +- **Recently added contacts**: A contact added 1 day ago with no notes shouldn't be Critical. The flag criteria have time thresholds for a reason — respect them. +- **Pipeline stage naming**: Different GHL accounts use different pipeline naming conventions. Map stage names to behavioral expectations contextually, not rigidly. "Nurture" and "Long Term Nurture" and "Drip" all mean the same thing. + +--- + +## Output Specs — Branded HTML + PDF + +When generating reports as HTML or PDF, use these brand specs: + +### Colors +- **Background (primary):** Deep Slate `#1a2744` +- **Background (secondary/cards):** `#1e2d4d` +- **Accent:** `#2d4278` +- **Text (primary):** `#ffffff` +- **Text (secondary):** `#a0b0c8` +- **Critical/Red:** `#e74c3c` +- **Warning/Gold:** `#f39c12` +- **Healthy/Green:** `#27ae60` +- **HOT bucket:** `#e74c3c` (red) +- **WARM bucket:** `#f39c12` (gold) +- **FOLLOW UP bucket:** `#3498db` (blue) +- **LONG TERM bucket:** `#a0b0c8` (muted blue-gray) +- **DEAD bucket:** `#6c757d` (gray) +- **Pipeline Mismatch indicator:** `#e67e22` (orange) + +### Typography +- Headers: Bold, white, generous spacing +- Body text: Clean sans-serif, good line height for readability +- Numbers/scores: Large, bold, color-coded by health + +### Layout +- Pipeline Health Score displayed as a large circular gauge or bold number with letter grade +- Bucket breakdown as color-coded horizontal bar or pill badges +- Today's Top 10 as card-style entries with clear visual hierarchy +- Mismatch callouts as bordered alert boxes (orange border for ⚡, yellow for ⚠️, red for 🚫) +- Adrian's Task List as a clean numbered checklist with priority color coding +- Full Contact Register as a sortable table with alternating row shading + +--- + +## Safety Rules + +These rules apply at all times during this skill: + +- Always confirm before executing any write action (enrolling, messaging, tagging, task creation, pipeline moves) +- Always give a preview of any SMS or note content before sending +- Never execute on more than 50 contacts at once without checking in with the user first +- If GHL rate limit is hit (100 requests per 10 seconds), pause automatically and resume — tell the user you're waiting +- Keep a running log of everything done in the session so the user can reference it +- If the user says STOP at any time, halt all execution immediately and report status +- If the user says REPORT, give a summary of everything completed so far this session + +--- + +## Tone and Communication + +- Be direct and clear. Explain technical concepts in plain English. +- When presenting the audit report, let the data speak — don't editorialize about how "bad" the CRM is. Just present what you found and what the recommended actions are. +- When executing actions, give clear progress updates so the user always knows where things stand. +- If something fails, explain what happened, why it likely happened, and what to do next. Never just say "error occurred." +- Draft messages should sound like Graeham — warm, personal, direct, not salesy or robotic. Think "trusted advisor checking in" not "automated drip email." +- Adrian's Task List should be written so Adrian can execute without needing to interpret anything. Specific names, phone numbers, actions, and context for every task. diff --git a/skills/ghl-crm-audit/generated/.gitkeep b/skills/ghl-crm-audit/generated/.gitkeep new file mode 100755 index 0000000..e69de29 diff --git a/skills/ghl-crm-audit/references/flag-criteria.md b/skills/ghl-crm-audit/references/flag-criteria.md new file mode 100755 index 0000000..19f62b6 --- /dev/null +++ b/skills/ghl-crm-audit/references/flag-criteria.md @@ -0,0 +1,318 @@ +# Flag Criteria, Behavioral Buckets & Scoring Reference + +This document defines the complete audit framework: behavioral buckets, flag overlay system, priority scoring formula, Pipeline Health Score calculation, Pipeline Mismatch Detection rules, and edge cases. + +Read this file before generating any audit report. The SKILL.md summarizes these rules, but this file is the authoritative source for thresholds, scoring weights, and edge case handling. + +--- + +## Table of Contents + +1. [Behavioral Buckets (5-Bucket System)](#1-behavioral-buckets) +2. [Flag Overlay System (Critical / Warning / Watch)](#2-flag-overlay-system) +3. [Pipeline Mismatch Detection](#3-pipeline-mismatch-detection) +4. [Priority Scoring Formula](#4-priority-scoring-formula) +5. [Pipeline Health Score Calculation](#5-pipeline-health-score-calculation) +6. [Adrian's Task List Prioritization](#6-adrians-task-list-prioritization) +7. [Edge Cases & Special Rules](#7-edge-cases--special-rules) +8. [Bucket-to-Pipeline Mapping](#8-bucket-to-pipeline-mapping) + +--- + +## 1. Behavioral Buckets + +Every contact is assigned to exactly ONE bucket based on their most recent **meaningful** activity. The bucket determines the baseline urgency and expected follow-up cadence. + +### What counts as "meaningful activity" + +Meaningful activity is any engagement that indicates the contact is alive and reachable. This includes: + +**Inbound (strongest signal):** +- Replied to a message (SMS, email, chat) +- Submitted a form or survey +- Booked or requested an appointment +- Saved a property on IDX / portal +- Logged into a property search portal +- Called or texted the agent/team directly +- Clicked a link in an email or SMS + +**Outbound (human only):** +- Agent/team member sent a personal message (not automated) +- Agent/team member left a voicemail +- Agent/team member added a manual note documenting a conversation +- Agent/team member completed a task related to this contact + +**What does NOT count as meaningful activity:** +- Automated workflow emails sent (these are system touches, not engagement) +- Automated SMS from drip campaigns with no reply +- System-generated notes (e.g., "Contact added to workflow") +- Tags being applied or removed by automation +- Pipeline stage changes made by automation (not human) + +### Bucket Definitions + +| Bucket | Timeframe | Description | Expected Follow-up Cadence | +|--------|-----------|-------------|---------------------------| +| **HOT** | 0-7 days | Active engagement within the last 7 days. This contact is in-market and responsive right now. | Every 1-2 days. These contacts should have a task assigned for the next touch within 48 hours at most. | +| **WARM** | 8-30 days | Activity within 8-30 days. Still engaged but momentum is slowing. Without re-engagement, they'll drift to FOLLOW UP. | Every 3-5 days. Should have at least one meaningful touch per week. | +| **FOLLOW UP** | 31-90 days | No meaningful activity in 31-90 days. They haven't opted out but they've gone quiet. Needs a re-engagement attempt. | Every 7-14 days. At least two touch attempts per month. These contacts need creative re-engagement — not just "checking in." | +| **LONG TERM** | 91+ days | No meaningful activity in 91+ days. Still a valid contact (not DND, not bounced) but not actively in-market. Worth periodic touchpoints but not urgent. | Monthly at most. Add to a long-term drip workflow. Quarterly personal check-in. | +| **DEAD** | N/A | Contact has opted out (DND status), hard-bounced on all channels (email + phone), or explicitly stated they are not interested with no subsequent re-engagement. | None. Do not contact. Review quarterly to see if DND was removed or if they re-engaged through another channel. | + +### Bucket Assignment Rules + +1. Use the **most recent meaningful activity date** to determine the bucket. +2. If a contact has BOTH inbound and outbound activity, use whichever is more recent. +3. If a contact has NO meaningful activity ever (zero notes, zero messages, zero appointments), assign based on date added to CRM and apply CRITICAL flag. +4. DND status immediately overrides to DEAD, regardless of activity recency. +5. If the only activity is automated workflow touches, treat the contact as if they have no activity — use the date of the last human touch or last inbound engagement. + +--- + +## 2. Flag Overlay System + +Flags are an urgency overlay on top of buckets. A HOT contact can be flagged CRITICAL (e.g., hot lead with no task assigned). A LONG TERM contact can be flagged WATCH (e.g., showing early re-engagement signals). Flags highlight what needs attention RIGHT NOW, while buckets describe the contact's overall engagement state. + +### CRITICAL (Red) — Immediate Action Required + +A contact gets a CRITICAL flag if ANY of the following are true: + +| # | Condition | Why It Matters | +|---|-----------|---------------| +| C1 | Zero notes ever AND zero outbound contact attempts | This contact was added to the CRM and completely forgotten. | +| C2 | Not enrolled in any workflow AND no outbound in last 14 days | No automated nurture AND no human follow-up — they're getting zero attention. | +| C3 | Last outbound was 10+ days ago with no response and no follow-up scheduled | The ball was dropped. Someone reached out, got no reply, and never followed up. | +| C4 | HOT bucket contact with no task assigned for next step | Your hottest leads need a next action. No task = no plan. | +| C5 | Open opportunity (any dollar value) with no follow-up in 5+ days | Money is on the table and nobody's tending to it. | +| C6 | Appointment no-showed and no follow-up within 48 hours | They didn't show up and nobody reached out to reschedule. | +| C7 | Inbound message received with no response in 48+ hours | They reached out to YOU and got ignored. | + +### WARNING (Yellow) — Needs Attention This Week + +A contact gets a WARNING flag if ANY of the following are true (and they don't already qualify for CRITICAL): + +| # | Condition | Why It Matters | +|---|-----------|---------------| +| W1 | Open task 3+ days overdue | Someone committed to a next step and hasn't done it. | +| W2 | No pipeline stage assigned | The contact exists in a void — not being tracked through any process. | +| W3 | Last outbound was 5-9 days ago with no follow-up | Approaching the danger zone. One more week and this becomes Critical. | +| W4 | Added 5+ days ago with no appointment ever booked | They entered the CRM but the goal (booking an appointment) hasn't been pursued. | +| W5 | WARM bucket contact with no re-engagement action planned | They're cooling off and there's no plan to re-engage before they go cold. | +| W6 | Pipeline mismatch: cold behavior in hot pipeline stage | Pipeline says active, behavior says otherwise. Needs review. | +| W7 | Has notes but no task assigned for next step | Previous conversations happened but nobody scheduled the follow-through. | + +### WATCH (Green) — Monitor / Low Urgency + +A contact gets a WATCH flag if ANY of the following are true (and they don't qualify for CRITICAL or WARNING): + +| # | Condition | Why It Matters | +|---|-----------|---------------| +| G1 | Added 2-4 days ago with no appointment yet | Still within reasonable window but worth tracking. | +| G2 | Enrolled in active workflow but no human outreach logged | Automation is covering them but no personal touch yet. | +| G3 | LONG TERM contact showing early re-engagement signals | They clicked an email, visited the portal, or did something after months of silence. Could be coming back to market. | +| G4 | Pipeline mismatch: hot behavior in cold/nurture pipeline | Behavior suggests they should be upgraded but the pipeline hasn't caught up yet. Worth flagging for review, not urgent. | + +### Flag Priority + +If a contact qualifies for multiple flag levels, use the highest: CRITICAL > WARNING > WATCH. + +--- + +## 3. Pipeline Mismatch Detection + +A pipeline mismatch occurs when a contact's behavioral bucket (based on actual activity data) disagrees with their GHL pipeline stage (which is often set manually or by old automations and may be stale). + +### Mismatch Types + +**Type 1: Hot Behavior / Cold Pipeline (⚡ — Most Urgent)** +- Behavioral bucket: HOT or WARM +- Pipeline stage: Any nurture, long-term, drip, or inactive stage +- Evidence required: At least one meaningful inbound activity in the bucket's timeframe +- Callout format: `⚡ PIPELINE MISMATCH — This contact is in your '[Stage Name]' pipeline but shows [BUCKET] behavioral activity ([specific evidence]). The GHL label may be outdated.` +- Example: `⚡ PIPELINE MISMATCH — This contact is in your 'Long Term Nurture' pipeline but shows HOT behavioral activity (3 IDX property saves, inbound message 2 days ago). The GHL label may be outdated.` + +**Type 2: No Pipeline / Active Behavior (⚡ — Urgent)** +- Behavioral bucket: HOT or WARM +- Pipeline stage: None (not assigned to any pipeline) +- Evidence required: Recent meaningful activity +- Callout format: `⚡ PIPELINE MISMATCH — This contact has recent activity ([evidence]) but isn't assigned to any pipeline. They need to be placed.` + +**Type 3: Cold Behavior / Hot Pipeline (⚠️ — Needs Review)** +- Behavioral bucket: FOLLOW UP, LONG TERM, or DEAD +- Pipeline stage: Any "active," "hot," "ready," "engaged," or similar active stage +- Evidence required: No meaningful activity in 30+ days +- Callout format: `⚠️ PIPELINE MISMATCH — This contact is in your '[Stage Name]' pipeline but has had no activity in [X] days. Consider moving to [recommended stage].` +- Example: `⚠️ PIPELINE MISMATCH — This contact is in your 'Active Buyer' pipeline but has had no activity in 47 days. Consider moving to FOLLOW UP.` + +**Type 4: Dead Behavior / Active Pipeline (🚫 — Cleanup)** +- Behavioral bucket: DEAD +- Pipeline stage: Any active or nurture stage (anything that implies outreach will continue) +- Evidence required: DND status or bounced on all channels +- Callout format: `🚫 PIPELINE MISMATCH — This contact is DND/bounced but still in '[Stage Name]'. Remove from active pipeline.` + +### Mismatch Detection Rules + +1. **Map pipeline stages to expected buckets.** Since every GHL account uses different pipeline names, you need to infer the intent of each stage name. See Section 8 for the mapping logic. + +2. **A mismatch exists when the expected bucket for the pipeline stage is 2+ levels away from the actual bucket.** The severity ladder is: HOT → WARM → FOLLOW UP → LONG TERM → DEAD. Being one level off (e.g., WARM contact in a HOT pipeline) is minor and doesn't get flagged. Being two or more levels off (e.g., FOLLOW UP contact in a HOT pipeline) is a mismatch. + +3. **No pipeline = always a mismatch if the contact is HOT or WARM.** Any active contact should be tracked in a pipeline. + +4. **DEAD contacts in ANY active pipeline are always a mismatch.** No exceptions. + +5. **When in doubt, flag it.** It's better to surface a borderline mismatch for human review than to miss a lead stuck in the wrong pipeline. + +--- + +## 4. Priority Scoring Formula + +Every contact gets a priority score from 0-100. This determines their position in the Today's Top 10 and the order of Adrian's Task List. + +### Score Components + +| Component | Max Points | Calculation | +|-----------|-----------|-------------| +| **Recency** | 30 | Based on days since last meaningful activity. 0 days = 30 pts. 1-3 days = 25 pts. 4-7 days = 20 pts. 8-14 days = 15 pts. 15-30 days = 10 pts. 31-60 days = 5 pts. 61-90 days = 2 pts. 91+ days = 0 pts. | +| **Opportunity Value** | 25 | $0 = 0 pts. $1-$99K = 5 pts. $100K-$299K = 10 pts. $300K-$499K = 15 pts. $500K-$749K = 20 pts. $750K+ = 25 pts. Only open opportunities count. | +| **Flag Severity** | 20 | CRITICAL = 20 pts. WARNING = 10 pts. WATCH = 5 pts. No flag = 0 pts. | +| **Pipeline Mismatch** | 15 | Type 1 (Hot/Cold) = 15 pts. Type 2 (No Pipeline) = 12 pts. Type 3 (Cold/Hot) = 8 pts. Type 4 (Dead/Active) = 5 pts. No mismatch = 0 pts. | +| **Engagement Trajectory** | 10 | Moving up (FOLLOW UP→WARM or WARM→HOT) = 10 pts. Stable = 5 pts. Moving down (HOT→WARM or WARM→FOLLOW UP) = 8 pts (declining is urgent too). Stable LONG TERM or DEAD = 0 pts. | + +### Total Score +Sum all components. Maximum possible: 100. + +### Tiebreaker +If two contacts have the same score, break ties by: +1. Higher flag severity wins +2. Higher opportunity value wins +3. More recent activity wins +4. Earlier date added to CRM wins (older contact = more overdue) + +--- + +## 5. Pipeline Health Score Calculation + +The Pipeline Health Score is a single number (0-100) that represents overall CRM hygiene. It's a quick way for the user to see "how healthy is my database?" at a glance. + +### Components + +| Component | Weight | What It Measures | Scoring | +|-----------|--------|-----------------|---------| +| **Pipeline Coverage** | 20% | % of contacts assigned to a pipeline | 100% coverage = 20 pts. Linear scale down. | +| **Note Freshness** | 20% | % of non-DEAD contacts with a note in last 30 days | 100% = 20 pts. Linear scale down. | +| **Task/Workflow Coverage** | 20% | % of non-DEAD contacts with an active task or workflow enrollment | 100% = 20 pts. Linear scale down. | +| **Pipeline Accuracy** | 20% | % of contacts whose pipeline stage matches their behavioral bucket (inverse of mismatch rate) | 0% mismatches = 20 pts. Each mismatch reduces proportionally. | +| **Follow-up Timeliness** | 20% | % of contacts with follow-up scheduled within appropriate cadence for their bucket | HOT contacts followed up within 48h, WARM within 7 days, etc. 100% on-cadence = 20 pts. | + +### Total Score +Sum all components. Maximum: 100. + +### Letter Grade +- **A (90-100):** Excellent. CRM is well-maintained, contacts are tracked, follow-ups are timely. +- **B (80-89):** Good. Minor gaps but overall healthy. +- **C (70-79):** Fair. Noticeable gaps in follow-up or pipeline tracking. +- **D (60-69):** Poor. Significant number of neglected contacts or stale pipelines. +- **F (Below 60):** Critical. The CRM needs major attention. Many contacts are being missed. + +--- + +## 6. Adrian's Task List Prioritization + +Adrian is the team coordinator. The task list is organized so Adrian can work through it top to bottom without needing to make judgment calls about what's most important. + +### Task Priority Order + +1. **Tier 1 — Today's Top 10 actions** (highest priority score contacts) + - Each task includes: contact name, phone number, specific action, context for why + - Example: "Call Sarah Chen at (408) 555-7890 — HOT lead, saved 3 properties in Los Gatos yesterday, no one has called her yet. Reference the Elm Street listing she saved." + +2. **Tier 2 — Remaining Critical contacts** + - Same format as Tier 1 but these didn't make the Top 10 + - Grouped by flag reason (all "no notes ever" together, all "dropped follow-up" together) + +3. **Tier 3 — Warning contacts with overdue tasks** + - Focus on the specific overdue action + - Example: "Complete overdue task for Mike Rodriguez — was due 4 days ago: 'Send market update for Willow Glen area'" + +4. **Tier 4 — Pipeline mismatch fixes** + - Specific pipeline move instructions + - Example: "Move James Park from 'Long Term Nurture' → 'Active Buyer' pipeline — he saved 5 properties this week and messaged Graeham 3 days ago" + +5. **Tier 5 — Data hygiene** + - Missing pipeline assignments to resolve + - Contacts with no tags to categorize + - Possible duplicates to review + - Stale workflow enrollments to clean up + +### Task Format + +Every task follows this template: +``` +[Priority #] [Action verb] [Contact name] at [phone/email] — [Context: what happened, what's needed, why it matters]. [Specific instruction: what to say, what to reference, what to schedule.] +``` + +Never write vague tasks like "Follow up with John" or "Check on this contact." Every task must be specific enough that Adrian can execute it without asking Graeham for clarification. + +--- + +## 7. Edge Cases & Special Rules + +### New Contacts (Added < 48 hours ago) +- Do not flag as CRITICAL regardless of note count +- Assign to HOT bucket if they came in via form submission, appointment request, or inbound message +- Assign to WATCH if they were manually added or imported with no inbound activity +- Flag as WARNING only if they submitted an urgent form (e.g., "I want to sell my house") and no one has responded in 24+ hours + +### Contacts with ONLY Automated Activity +- If the only outbound is automated workflows (no human notes, no manual messages), treat them as if they have no outbound activity for flag purposes +- Exception: If the contact has replied to an automated message, that reply counts as meaningful inbound activity + +### Contacts in Multiple Pipelines +- Use the pipeline stage that was most recently updated +- If they're in both an "Active Buyer" and "Seller Listing" pipeline, check both — a mismatch on either one gets flagged + +### Opportunity with No Contact Record Details +- If an opportunity exists but the contact has no phone AND no email, flag as CRITICAL with note: "Open opportunity but no contact method on file — verify contact details" + +### DND Contacts with Recent Inbound Activity +- If a contact is DND but sent an inbound message after the DND was set, flag as CRITICAL: "Contact is DND but reached out on [date] — their DND may need to be reviewed" +- Do NOT auto-remove DND. Just flag it for human review. + +### Contacts with Very High Activity +- If a contact has 10+ meaningful activities in the last 7 days, they're HOT but may also need a "high engagement" note so the team knows this is someone who's very active right now +- Add a note in their Top 10 entry: "🔥 High engagement — [X] activities in last 7 days" + +### Team-Assigned Contacts +- If tasks or notes show a specific team member's name, include that in Adrian's task list: "This contact has been worked by [team member name] — coordinate with them before reassigning" + +--- + +## 8. Bucket-to-Pipeline Mapping + +Since every GHL account uses different pipeline and stage names, the mismatch detection system needs to infer intent from stage names. Here's the mapping logic: + +### Pipeline Stages That Imply HOT/WARM (Active) +Keywords to look for: "active," "hot," "engaged," "ready," "qualified," "appointment set," "showing," "under contract," "offer," "negotiating," "closing" + +Expected bucket: HOT or WARM. Flag a mismatch if actual bucket is FOLLOW UP, LONG TERM, or DEAD. + +### Pipeline Stages That Imply FOLLOW UP +Keywords: "follow up," "follow-up," "callback," "retry," "re-engage," "attempted," "no answer" + +Expected bucket: WARM or FOLLOW UP. Flag a mismatch if actual bucket is HOT (they're more active than the pipeline suggests) or LONG TERM/DEAD (they're less active). + +### Pipeline Stages That Imply LONG TERM / Nurture +Keywords: "nurture," "long term," "long-term," "drip," "future," "not ready," "6 months," "next year," "sphere," "past client" + +Expected bucket: FOLLOW UP or LONG TERM. Flag a mismatch if actual bucket is HOT or WARM. + +### Pipeline Stages That Imply DEAD / Closed +Keywords: "dead," "lost," "closed lost," "unqualified," "do not contact," "junk," "spam," "wrong number" + +Expected bucket: DEAD. Flag a mismatch if actual bucket is anything else AND the contact has recent activity. + +### No Pipeline Assigned +Expected: Only acceptable for LONG TERM or DEAD contacts. Any HOT or WARM contact without a pipeline is always a mismatch. FOLLOW UP contacts without a pipeline get a WARNING flag. + +### When Stage Names Are Ambiguous +If you can't confidently map a stage name to an expected bucket, don't guess — note the ambiguity in the report and suggest the user clarify what that stage means in their workflow. Better to ask than to generate false mismatches. diff --git a/skills/github-repo-analyzer/SKILL.md b/skills/github-repo-analyzer/SKILL.md new file mode 100755 index 0000000..126a2fc --- /dev/null +++ b/skills/github-repo-analyzer/SKILL.md @@ -0,0 +1,365 @@ +--- +name: github-repo-analyzer +description: "GitHub Repository & Developer Activity Analyzer. Use ANY time user mentions: GitHub repo review, code review, developer activity, commit history analysis, PR review, pull request audit, repo health check, code quality audit, developer productivity, sprint review, dev team analysis, GitHub audit, repo analysis, codebase review, contributor analysis, branch strategy review, merge patterns, or anything related to analyzing GitHub repositories or developer work patterns." +--- + +# GitHub Repository & Developer Activity Analyzer + +You are a GitHub Repository Analyzer. Your job is to connect to GitHub repos, pull comprehensive data about code, commits, PRs, and developer activity, then deliver clear, actionable reports the user can use to manage their development team. + +**Before starting, read the reference files:** +- `references/review-criteria.md` — Defines the analysis framework, flag system, and report structure + +--- + +## How This Works + +The user (typically a project owner or team lead) wants visibility into what's happening in their GitHub repositories. They want to know: who's active, who's falling behind, what's the code quality like, are PRs getting reviewed, are there bottlenecks, and is the project on track. + +This skill has three modes: + +1. **Repo Health Check** — Analyze a single repository's overall health (activity, code quality signals, branch hygiene, CI status) +2. **Developer Activity Review** — Analyze what specific developers have been doing (commits, PRs, reviews, patterns) +3. **Sprint/Period Review** — Analyze all activity in a repo over a specific time period (last week, last sprint, last 30 days) + +The user can request any mode or combine them. Ask which mode they want if it's not obvious from their request. + +--- + +## Phase 0: Repository Verification & Attribution (MANDATORY) + +**This phase must run BEFORE any analysis begins.** Skipping this phase risks analyzing the wrong repos, attributing work to the wrong people, or scoring the client's own work as the dev team's output. + +### Step 1: Confirm Repo Ownership + +For every repository in scope, determine: +- **Who owns the GitHub account?** (client or dev team) +- **Who built this repo?** (client, current dev team, previous dev team, or mixed) +- **Is the current dev team actively committing here?** + +Build a Repo Attribution Table: + +| Repository | GitHub Owner | Built By | Current Team Active? | Include in Audit? | Notes | +|------------|-------------|----------|---------------------|-------------------|-------| +| [repo] | [owner] | [who] | [yes/no] | [yes/no] | [reason] | + +### Step 2: Filter Out Previous Developers + +If the user identifies previous developers or teams, collect their GitHub usernames and **exclude their commits from all current-team scoring.** Their commits should still appear in the report as "Historical — Previous Team" for context, but must not affect health scores or developer scorecards. + +### Step 3: Detect External Tool Development Pattern + +Check for signals that the team is developing on their own internal tools and only pushing finished code to the client's repos. See `references/review-criteria.md` → "External Tool Development Pattern" for detection signals. + +If detected, this changes how you interpret ALL subsequent data: +- Commit frequency benchmarks are unreliable — shift to push frequency and code quality assessment +- Developer count verification becomes critical — bulk pushes may hide team size +- Add the "Code Ownership Governance" weighted factor to health scoring +- Flag the pattern explicitly in the report + +### Step 4: Check for Client Migration Requests + +Ask or check context: **Has the client requested that the team stop using internal tools and push directly to the client's repos?** +- If YES and the team has NOT complied → 🔴 CRITICAL governance flag +- If YES and the team is partially complying → 🟡 WARNING with migration timeline +- If NO request has been made → 🟡 WARNING recommending the client make this request + +--- + +## Connecting to GitHub + +### Option A — GitHub MCP (if available) +If the user has a GitHub MCP server connected, use it directly to pull data. + +### Option B — GitHub API via Claude in Chrome +If no MCP is available, use Claude in Chrome to navigate to GitHub and pull data directly from the web interface. + +### Option C — User provides data +The user may paste commit logs, PR lists, or other GitHub data directly. Work with whatever they provide. + +### What to ask for: +- Repository URL or owner/repo name +- Time period to analyze (default: last 14 days) +- Specific developers to focus on (or "all contributors") +- Any specific concerns they want investigated +- **Whether the dev team uses internal tools to develop before pushing to these repos** +- **Whether the client built any of the repos themselves** +- **Names/usernames of any previous developers to exclude** + +--- + +## Phase 1: Repository Health Check + +Pull and analyze the following data points: + +### Activity Metrics +- Total commits in the analysis period +- Total PRs opened, merged, and closed +- Average time from PR open to merge +- Number of open PRs right now (and how old they are) +- Number of open issues (and how old the oldest ones are) +- Branch count — active vs stale (no commits in 30+ days) +- **Push pattern analysis** — Are commits arriving incrementally (healthy) or in bulk batches (external tool signal)? + +### Code Quality Signals +- Are there CI/CD checks configured? Are they passing? +- Test coverage trends (if visible in CI badges or checks) +- Average PR size (lines changed) — flag PRs over 500 lines as hard to review +- Are PRs getting reviews before merge, or are people merging their own code? +- Frequency of force pushes to main/master + +### Branch Hygiene +- Is there a clear branching strategy (feature branches, release branches)? +- Stale branches that should be cleaned up +- Any long-lived feature branches that haven't been merged (potential merge conflict risk) +- **Unmerged feature branches with no associated PRs** — these may represent stalled or abandoned work + +### Documentation +- Does README exist and is it recently updated? +- **Does README accurately reflect the current tech stack?** (Flag if it describes an old/replaced architecture) +- Are there contributing guidelines? +- Is there a changelog or release notes pattern? + +### Governance & Ownership +- **Is the repo named correctly for its actual contents?** (Flag misnamed repos) +- **Are all billed developers visible as contributors?** +- **Is there evidence of external tool development?** (See Phase 0, Step 3) +- **Is code being developed in repos the client owns and can access at all times?** + +--- + +## Phase 2: Developer Activity Review + +For each developer being analyzed, pull: + +### Commit Activity +- Total commits in the period +- Commit frequency pattern (daily? sporadic? binge commits?) +- Average commit size (lines added/removed) +- Commit message quality — are they descriptive or just "fix" and "update"? +- What files/directories are they working in most? +- **Push pattern** — Incremental development commits or bulk pushes of completed features? + +### Pull Request Behavior +- PRs opened in the period +- PRs reviewed (as a reviewer) in the period +- Average time to review when assigned +- PR descriptions — are they detailed or empty? +- Self-merges vs peer-reviewed merges + +### Code Review Participation +- Reviews given to others +- Quality of review comments (rubber-stamp approvals vs substantive feedback) +- Response time to review requests + +### Red Flags to Watch For +- Long periods of zero activity followed by huge commits (possible deadline cramming OR external tool batch push) +- Only working in one area of the codebase (knowledge silo risk) +- Never reviewing others' code (not a team player pattern) +- Merging own PRs without review (bypassing quality gates) +- Commit times suggesting unsustainable work patterns +- **Single developer pushing code that represents multiple people's work** (external tool signal) +- **Billed developer with no GitHub activity whatsoever** (verify they exist and are assigned to visible repos) + +### Ghost Developer Detection + +When the number of active GitHub contributors is LESS than the number of billed developers: + +1. List all unique committer accounts across all repos in scope +2. Compare against the billed team size +3. For each "missing" developer, flag as 🔴 CRITICAL with a fairness section listing possible explanations: + - Working in repos the client can't see + - Pair programming under another account + - Non-code contributions (design, DevOps, planning) + - Recently hired / hasn't started committing yet + - Working on internal tool that hasn't been pushed yet +4. **Always recommend** the client request GitHub usernames for all billed developers and verify which repos each is assigned to + +--- + +## Phase 3: Sprint/Period Review + +Combine repo health and developer data into a period summary: + +### What Got Done +- Features/changes shipped (based on merged PRs and their descriptions) +- Issues closed +- Bugs fixed vs features added ratio +- **For external tool workflows: what code was pushed to client repos this period, and does it represent complete features?** + +### What Didn't Get Done +- PRs still open from this period +- Issues that were assigned but not resolved +- Any blocked or stalled work +- **Feature branches sitting unmerged with no PR** — quantify the commits at risk + +### Team Dynamics +- Who's carrying the load? (commit/PR distribution) +- Who's reviewing whose code? (review network) +- Any bottlenecks? (one person blocking multiple PRs) +- Collaboration patterns — are people working in silos or cross-pollinating? +- **Billed team size vs active contributor count** — is the full team visible? + +--- + +## Report Format + +### Flag System + +Apply flags to developers and to the repo overall, per the detailed criteria in `references/review-criteria.md`. + +**🔴 CRITICAL** — Immediate attention needed +**🟡 WARNING** — Needs attention soon +**🟢 WATCH** — Monitor, not urgent + +### Report Sections + +**Section 0 — Repo Attribution & Verification** (NEW — MANDATORY) +- Repo Attribution Table showing which repos belong to the dev team vs client vs previous team +- External tool development status (detected / confirmed / not detected) +- Migration compliance status (if client has made migration requests) +- Previous developers identified and excluded + +**Section 1 — Executive Summary** +- Repository name, analysis period, total contributors active +- Overall health score (Healthy / Needs Attention / At Risk) +- Top 3 findings that need action +- Quick stats: commits, PRs merged, avg merge time, open issues +- External tool workflow status (if applicable) + +**Section 2 — Repository Health** +- Activity trends, branch hygiene, CI status, documentation state +- Governance & ownership assessment +- Comparison to previous period if data available + +**Section 3 — Developer Scorecards** +For each developer: +- Flag level (Critical/Warning/Watch/Healthy) +- Activity summary (commits, PRs, reviews) +- Push pattern (incremental vs bulk) +- Strengths observed +- Areas for improvement +- Specific recommendations + +For ghost developers (billed but no activity): +- Flag as Critical +- Include fairness section with possible explanations +- Specific verification steps the client should take + +**Section 4 — Team Dynamics** +- Workload distribution chart/breakdown +- Review network (who reviews whom) +- Collaboration patterns +- Knowledge silo risks +- Billed vs visible developer gap analysis + +**Section 5 — Action Items** +Numbered, specific, actionable items prioritized as HIGH / MEDIUM / LOW + +**Section 6 — Recommendations** +Process improvements based on patterns observed, including: +- External tool migration plan (if applicable) +- PR/review workflow requirements +- CI/CD setup recommendations +- Governance improvements + +--- + +## Quality Control Verification (MANDATORY) + +**This step is not optional.** Before delivering any report, you MUST run a full verification pass. Developer reviews affect real people's careers and reputations. An inaccurate report — flagging someone as inactive when they were on PTO, or missing a developer who's actually falling behind — undermines the user's trust and can cause real team problems. + +### The Verification Process + +After generating the report, perform a distinct second pass. Do NOT just re-read what you wrote — go back to the source data (GitHub API results, commit logs, PR lists) and cross-check against the report. + +### What the Verification Checks + +**1. Repo Attribution Accuracy** +- Are you analyzing the right repos? (Not the client's self-built repos, not abandoned repos from previous teams) +- Is every repo correctly labeled in the attribution table? +- Were previous developer commits properly excluded from current-team scoring? + +**2. Data Accuracy** +- Re-count commits and PRs for every developer from the raw data. Does the report match? +- Verify date ranges — if the report says "last 14 days" make sure no commits outside that range were included or excluded +- Check that PR merge times are calculated correctly (opened date to merged date, not created date to closed date) +- Spot-check at least 3 specific claims (e.g., "Developer X opened 5 PRs") against the actual data + +**3. Flag Accuracy** +- Re-check every CRITICAL developer against the flag criteria in `references/review-criteria.md` +- Watch for these common errors: + - **False Critical**: Developer flagged for "zero commits" but they were doing code reviews, documentation, or non-code work + - **Missed context**: Developer was on PTO, recently hired, or working part-time — should adjust thresholds + - **Wrong period comparison**: "Commit count dropped 50%" but the comparison period included a holiday or sprint planning week + - **Bot/CI commits**: Automated commits (dependabot, CI, auto-formatting) inflating one developer's numbers or deflating another's + - **External tool false positive**: Developer appears inactive but is building on internal tool (still flag for governance, but note the nuance) + +**4. External Tool Pattern Verification** (if applicable) +- Confirm the external tool pattern is real and not just a slow development period +- Check if the client has explicitly requested migration — if yes, verify compliance status is accurately reported +- Ensure governance flags match the criteria in review-criteria.md + +**5. Fair Assessment** +- For every developer flagged Critical or Warning, ask: "Is there a reasonable explanation I haven't considered?" +- If the user hasn't mentioned PTO, hiring dates, or role changes, and a developer shows unusual patterns, note the uncertainty rather than making a definitive negative judgment +- Make sure the report doesn't compare a junior developer's output to a senior's without noting the context + +**6. PR and Review Metrics** +- Verify self-merge counts — check that the PR author actually merged their own PR (not that someone with a similar name did) +- Check that "reviews given" counts actual review submissions, not just comments +- Verify "average review time" isn't skewed by a single outlier + +**7. Completeness** +- Did you cover every developer the user asked about? +- Did you cover every metric relevant to the analysis mode? +- If any API calls failed or returned incomplete data, note it explicitly +- Did you include the Repo Attribution Table? +- Did you address external tool workflow if applicable? + +**8. Tone Check** +- Scan for language that could feel like a personal attack rather than a data observation +- Replace "Developer X is not contributing" with "Developer X had [N] commits this period, below the team average of [Y]" +- Make sure positive findings are highlighted too, not just problems +- Check that recommendations are constructive + +### Verification Output + +Fix any errors found during verification. If a developer's flag level changed, update the report and mention the correction to the user. If any metric was wrong, correct it. + +**Only deliver the report after verification is complete.** + +### Common Pitfalls + +- **Bot commits**: Dependabot, auto-formatters, and CI bots can inflate commit counts. Filter these out or note them separately. +- **Squash merges hiding work**: If the repo uses squash-and-merge, a developer who made 50 commits across a feature branch shows up as 1 commit on main. Check PR commit counts, not just main branch commits. +- **Timezone issues**: GitHub API returns timestamps in UTC. A commit at 11 PM PST on Friday shows as Saturday UTC. +- **Multiple accounts**: Some developers use different GitHub accounts. If commit patterns look unusual, ask the user. +- **Non-code contributions**: Some developers contribute through issues, project management, design, or documentation that doesn't show in commit stats. +- **External tool batch pushes**: Don't interpret a bulk push as "one day of work" — it may represent weeks of development done elsewhere. Flag the pattern, but don't use it to claim the developer only worked one day. +- **Misattribution**: The most damaging error. Always verify WHO built WHICH repo before scoring. Praising the dev team for the client's work (or vice versa) destroys credibility. + +--- + +## Output Options + +Ask the user how they want the report: + +1. **In-chat summary** — Quick overview right here in the conversation +2. **HTML report** — Branded, formatted report saved as a file (recommended for sharing) +3. **Markdown report** — Clean markdown file for documentation +4. **Spreadsheet** — Developer metrics in an Excel file for tracking over time + +Default to HTML report unless the user specifies otherwise. + +--- + +## Tone and Communication + +- Be direct about what you find. If a developer isn't pulling their weight, say so clearly but professionally. +- Frame findings as "observations" not "accusations" — you're providing data, the user makes the people decisions. +- When you see good patterns, call them out too. Positive reinforcement matters. +- If the data is limited (small repo, few commits), say so upfront and adjust expectations. +- Explain technical GitHub concepts in plain English when needed — the user may not be a developer themselves. +- **When external tool patterns are detected**, explain the governance risk clearly: the client is paying for code they can't see being built, and if the engagement ends, unfinished work may never be delivered. +- **When ghost developers are flagged**, be fair but direct: the data shows zero activity, here are possible explanations, but the client needs to verify. diff --git a/skills/github-repo-analyzer/references/review-criteria.md b/skills/github-repo-analyzer/references/review-criteria.md new file mode 100755 index 0000000..405db31 --- /dev/null +++ b/skills/github-repo-analyzer/references/review-criteria.md @@ -0,0 +1,303 @@ +# GitHub Repo Analyzer — Review Criteria & Benchmarks + +## Developer Activity Benchmarks + +Use these as baseline expectations. Adjust based on team size, project phase, and role. + +### Healthy Activity Levels (per 2-week sprint) + +| Metric | Healthy | Warning | Critical | +|--------|---------|---------|----------| +| Commits | 10+ | 3-9 | 0-2 | +| PRs opened | 3+ | 1-2 | 0 | +| PRs reviewed (for others) | 2+ | 1 | 0 | +| Avg days to review assigned PR | < 1 day | 1-3 days | 3+ days | +| Avg PR size (lines changed) | < 300 | 300-500 | 500+ | + +**Important context adjustments:** +- Part-time contributors: Cut all thresholds in half +- Team leads: May have fewer commits but should have MORE reviews +- New team members (first 30 days): Expect lower numbers as they ramp up +- Sprint planning / design phases: Lower commit volume is normal +- External development tools: See "External Tool Development Pattern" section below + +--- + +## External Tool Development Pattern + +Some outsourced teams use their own internal development environments, IDEs, or platforms to build code — then push finished or near-finished code to the client's GitHub repos in bulk. This creates a distinct pattern that the analyzer must detect, flag, and account for. + +### Why This Matters + +When a team develops on an internal tool and only pushes to the client's GitHub when features are "done": +1. **The client loses real-time visibility** into development progress +2. **Commit history is compressed** — weeks of incremental work shows up as a few large commits +3. **Code review is impossible** during development — the client only sees the final output +4. **Risk accumulates silently** — bugs, architectural issues, and scope drift are invisible until the push +5. **The client doesn't own the work-in-progress** — if the engagement ends, unfinished code may never be delivered +6. **Standard commit frequency benchmarks don't apply** — a developer may be active but invisible + +### Detection Signals + +Flag a repository for "External Tool Development Pattern" when you observe: + +| Signal | What It Looks Like | +|--------|-------------------| +| Bulk push pattern | Large number of files/lines committed in a single push or a short burst (1-2 days), followed by weeks of silence | +| Initial commit is fully built | First commit contains a complete or near-complete application structure, not gradual buildout | +| Low commit frequency, high commit size | Few commits but each one changes hundreds or thousands of lines | +| Missing incremental history | No "work in progress" commits, no iterative debugging trail — code appears fully formed | +| Commit timestamps clustered | All commits within a few hours, suggesting a batch push from another system | +| Single contributor across large codebases | One developer account pushes everything, but the volume implies multiple people's work | +| No branch/PR development cycle | Features appear directly on main or dev branch without feature branch → PR → merge flow | + +### Adjusted Benchmarks for External Tool Workflows + +When external tool development is detected, standard commit frequency benchmarks are **not reliable** indicators of developer activity. Instead, shift analysis to: + +| Metric | What to Evaluate Instead | +|--------|-------------------------| +| Commit frequency | **Push frequency** — How often does code arrive in the client's repo? Weekly pushes = acceptable. Monthly = governance risk | +| Developer count | **Unique committer count vs billed team size** — If 4 devs are billed but 1 pushes, the others are invisible | +| Code quality | **Code structure and architecture quality** of what was pushed, since you can't evaluate the development process | +| Progress tracking | **Feature completeness per push** — Is shipped code functional, or are there half-built features? | +| Collaboration | **Cannot be assessed** — internal tool collaboration is invisible to the client | + +### Governance Flags for External Tool Workflows + +| Flag | Condition | Severity | +|------|-----------|----------| +| 🔴 CRITICAL | Client has explicitly requested team push to client repos and team has not complied | CRITICAL — Governance violation | +| 🔴 CRITICAL | Client cannot verify which developers are working due to single-account pushes | CRITICAL — Accountability gap | +| 🟡 WARNING | Team is developing externally but pushing regularly (weekly or better) | WARNING — Acceptable interim, needs migration plan | +| 🟡 WARNING | Repo shows bulk-push pattern but client hasn't explicitly required real-time commits | WARNING — Recommend requiring it | +| 🟢 WATCH | Team uses external tools for CI/testing but commits incrementally to client repo | WATCH — Acceptable workflow | + +### Recommended Actions When External Tool Pattern Is Detected + +1. **Require immediate migration to client GitHub repos** — All active development should happen in repos the client owns and can monitor +2. **Require daily or per-feature-branch pushes** — Even if the team uses internal tools for testing, code should be pushed to the client repo incrementally, not in bulk +3. **Establish branch protection + PR requirements** — Forces the team to use PRs for integration, creating visibility even if they develop elsewhere +4. **Request full team GitHub access** — All developers should have individual accounts pushing commits, not one person batch-pushing everyone's work +5. **Set up a migration deadline** — Give the team a specific date (e.g., 7 business days) to move all active work to client repos +6. **If non-compliant after deadline** — Escalate to contract/engagement terms review + +--- + +## Repo Verification Checklist + +Before analyzing, verify you are looking at the correct repositories. This prevents wasting time auditing repos the client built themselves or that belong to a previous engagement. + +### Pre-Analysis Questions + +1. **Who owns these repos?** — Is the client the GitHub owner, or is the dev team hosting them? +2. **Which repos does the dev team actively commit to?** — Get explicit confirmation, not assumptions +3. **Are there repos the team uses that the client doesn't have access to?** — If yes, flag immediately +4. **Did the client build any of these repos themselves?** — Exclude client-built repos from team performance scoring +5. **Are there previous developers whose commits should be excluded?** — Get names/usernames to filter out + +### Repo Attribution Table + +Before scoring, build a clear attribution table: + +| Repository | Who Built It | Current Team Active? | Include in Audit? | +|------------|-------------|---------------------|-------------------| +| [repo name] | [client / dev team / previous team] | [yes/no] | [yes/no — with reason] | + +This table must appear in the report. It prevents misattribution (e.g., praising the dev team for screens the client built, or flagging a repo as inactive when it was intentionally handed off). + +--- + +## Commit Quality Indicators + +**Good commit messages:** +- Start with a verb (Add, Fix, Update, Refactor, Remove) +- Reference issue/ticket numbers +- Explain WHY, not just WHAT +- Under 72 characters for the subject line + +**Red flag commit messages:** +- Single word: "fix", "update", "changes", "stuff" +- No issue/ticket reference on a team that uses issue tracking +- Extremely long messages that should have been PR descriptions +- "WIP" commits pushed to main branch + +### PR Quality Indicators + +**Good PR patterns:** +- Clear title and description +- Linked to an issue or ticket +- Reasonable size (under 300 lines ideal) +- Has at least one reviewer assigned +- CI checks pass before merge +- Conversation/feedback addressed before merge + +**Red flag PR patterns:** +- Empty description +- 1000+ lines changed (impossible to properly review) +- Self-approved and self-merged +- Merged with failing CI checks +- No linked issue (on teams that use issue tracking) +- Force-merged bypassing review requirements + +--- + +## Repository Health Scoring + +### Overall Health Score + +Calculate based on these weighted factors: + +| Factor | Weight | Healthy | Needs Attention | At Risk | +|--------|--------|---------|-----------------|---------| +| CI/CD status | 20% | All checks passing | Flaky tests | Failing on main | +| PR review rate | 20% | >80% reviewed before merge | 50-80% reviewed | <50% reviewed | +| Avg merge time | 15% | <2 days | 2-5 days | 5+ days | +| Branch hygiene | 10% | <5 stale branches | 5-15 stale | 15+ stale | +| Open PR age | 15% | All <3 days | Some 3-7 days | Any 7+ days | +| Issue management | 10% | Issues triaged and assigned | Backlog growing | Issues ignored | +| Documentation | 10% | README current, contributing guide exists | README outdated | No README | + +### Additional Factor: Code Ownership & Governance (applies when external tool pattern detected) + +When an external tool development pattern is detected, add this weighted factor: + +| Factor | Weight | Healthy | Needs Attention | At Risk | +|--------|--------|---------|-----------------|---------| +| Code ownership governance | 15% (redistributed from other factors) | All code in client repos, incremental commits, all devs visible | External tool used but regular pushes, migration plan in place | Client has requested migration and team has not complied | + +When this factor is added, redistribute weight by reducing CI/CD and PR review rate by 5% each, and Open PR age by 5% — because those metrics are less meaningful when the team isn't using the client's repo as their primary development environment. + +### Score Interpretation +- **Healthy (70-100%)**: Repo is well-maintained, team processes are working +- **Needs Attention (40-69%)**: Some areas slipping, targeted improvements needed +- **At Risk (0-39%)**: Significant process gaps, technical debt accumulating + +--- + +## Flag Criteria — Detailed + +### 🔴 CRITICAL — Developer Level + +| Condition | Why It Matters | +|-----------|---------------| +| Zero commits AND zero PRs in the full analysis period | Developer appears inactive | +| Assigned to issues/PRs but zero progress | Work is stalled, may be blocked | +| Merging own PRs to main with no review, repeatedly | Quality gates being bypassed | +| Breaking CI on main branch and not fixing it | Blocking the whole team | +| Billed developer with no GitHub username identifiable | Cannot verify work is being performed | + +### 🟡 WARNING — Developer Level + +| Condition | Why It Matters | +|-----------|---------------| +| Commit count dropped 50%+ vs previous period | Possible disengagement or blocker | +| Zero code reviews given to others | Not participating in team quality process | +| PRs averaging 500+ lines | Code is hard for others to review properly | +| Assigned reviews sitting unactioned for 3+ days | Blocking other developers | +| Only committing to one directory/module | Knowledge silo forming | +| Pushing bulk commits from external tool instead of incremental development | Process visibility gap | + +### 🟢 WATCH — Developer Level + +| Condition | Why It Matters | +|-----------|---------------| +| New to repo (first 30 days of commits) | Expected ramp-up period | +| Commit messages declining in quality | Minor but worth mentioning | +| Slightly fewer reviews than team average | Not urgent but track the trend | +| Working late/weekend commits increasing | Possible workload issue | + +### 🔴 CRITICAL — Repository Level + +| Condition | Why It Matters | +|-----------|---------------| +| CI/CD failing on main/master branch | Deployments blocked, team can't ship | +| PRs open 14+ days with no activity | Work is abandoned or stuck | +| No branch protection on main | Anyone can push directly, risky | +| Security vulnerabilities flagged by Dependabot/similar | Active security risk | +| Client requested code migration to their repos and team has not complied | Governance violation — client doesn't own the work they're paying for | +| Repo misnamed or mislabeled vs actual contents | Creates confusion about what's been built and what hasn't | + +### 🟡 WARNING — Repository Level + +| Condition | Why It Matters | +|-----------|---------------| +| 5+ stale branches (30+ days inactive) | Cluttered repo, potential merge conflicts | +| No CI/CD configured at all | No automated quality checks | +| README hasn't been updated in 90+ days | Documentation drifting from reality | +| Average merge time exceeding 5 days | Development velocity is slow | +| External tool development detected but no migration plan | Visibility and ownership risk accumulating | +| Feature branches unmerged with no PRs | Work may be stalled or abandoned | + +### 🟢 WATCH — Repository Level + +| Condition | Why It Matters | +|-----------|---------------| +| Test coverage declining (if trackable) | Quality may slip over time | +| Issue backlog growing faster than closing | Scope creep or understaffing | +| Release frequency slowing | May indicate complexity or blockers | + +--- + +## Report Templates + +### Executive Summary Template +``` +REPOSITORY: [repo name] +PERIOD: [start date] — [end date] +HEALTH SCORE: [X]% — [Healthy/Needs Attention/At Risk] + +QUICK STATS: +- [X] commits by [Y] contributors +- [X] PRs merged (avg [Y] days to merge) +- [X] open PRs | [X] open issues +- CI Status: [Passing/Failing/Not configured] + +EXTERNAL TOOL STATUS: [Not detected / Detected — see findings / Confirmed by client] +REPO VERIFIED: [Yes — confirmed as dev team repo / No — needs verification] + +TOP FINDINGS: +1. [Most important finding] +2. [Second most important] +3. [Third most important] +``` + +### Developer Scorecard Template +``` +DEVELOPER: @[username] +FLAG: [🔴/🟡/🟢/✅] +PERIOD: [dates] + +ACTIVITY: +- Commits: [X] ([up/down X%] vs previous period) +- PRs opened: [X] | PRs merged: [X] +- Reviews given: [X] | Avg review time: [X] days +- Primary work areas: [directories/modules] +- Push pattern: [Incremental / Bulk push / External tool suspected] + +STRENGTHS: +- [Positive observation] + +AREAS FOR IMPROVEMENT: +- [Constructive observation] + +RECOMMENDATION: +- [Specific action item] +``` + +--- + +## Context Questions to Ask + +Before running the analysis, gather context that affects interpretation: + +1. **Team structure** — How many developers? Full-time or part-time? Any contractors? +2. **Sprint cadence** — Weekly? Bi-weekly? Kanban (no sprints)? +3. **Current phase** — Building new features? Maintenance mode? Pre-launch crunch? +4. **Known absences** — Anyone on PTO or leave during the analysis period? +5. **Non-code work** — Are some developers doing design, planning, or documentation that won't show in commits? +6. **Specific concerns** — Is there a particular developer or issue they want investigated? +7. **Development environment** — Is the team developing directly in the client's GitHub repos, or do they use an internal tool/platform and push code periodically? (This is critical for interpreting commit patterns) +8. **Repo ownership** — Did the client build any of the repos being analyzed? (Exclude client-built repos from team scoring) +9. **Previous developers** — Are there commits from a prior team that should be filtered out? +10. **Migration requests** — Has the client asked the team to change their workflow (e.g., stop using internal tools, push to client repos)? If yes, has the team complied? diff --git a/skills/heygen-elevenlabs-renderer/SKILL.md b/skills/heygen-elevenlabs-renderer/SKILL.md new file mode 100644 index 0000000..07a255a --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/SKILL.md @@ -0,0 +1,151 @@ +--- +name: heygen-elevenlabs-renderer +description: End-to-end avatar video rendering pipeline for Graeham Watts. Synthesizes Graeham's cloned voice on ElevenLabs from an SSML script (including and tags from v5.4 format), uploads the MP3 to HeyGen, renders an avatar video against Graeham's personal avatar via the v3 Create Avatar Video endpoint, and returns a playable MP4. Use ANY time the user says "render this script", "make a video from this script", "auto-render", "full auto", "push this to HeyGen", "avatar video", "voice clone video", or clicks the "Full Auto-Render" button on the v5.4 weekly calendar. Also trigger when the content-creation-engine has produced a v5.4 script and the user wants the video built without manual steps. +--- + +# HeyGen + ElevenLabs Renderer + +## Purpose +Turn a finished v5.4 script into a delivered MP4 video with zero manual work. This skill owns the rendering layer of the content pipeline. Everything before this point is ideation and writing (content-calendar → content-creation-engine). Everything this skill does is mechanical execution. + +## Pipeline at a glance +``` +v5.4 SSML script + │ + ▼ +ElevenLabs TTS (Graeham's voice clone) ─── scripts/synthesize_voice.py + │ eleven_multilingual_v2 + ▼ Supports +audio.mp3 (44.1 kHz, 128 kbps mono) Accepts (silent pass-through) + │ + ▼ +HeyGen asset upload ─── scripts/upload_asset.py + │ Uses CLI: `heygen asset create --file` + ▼ +asset_id + │ + ▼ +HeyGen v3 /videos create (avatar + audio) ─── scripts/render_video.py + │ voice mode = audio_asset_id + ▼ +video_id (status: waiting → processing) + │ + ▼ +Poll /v1/video_status.get every 15s ─── scripts/poll_and_download.py + │ + ▼ +video_url (signed HeyGen CDN URL) + │ + ▼ +Download MP4 → outputs/renders/.mp4 +``` + +## Credentials +Store keys at `/sessions//mnt/outputs/.claude-credentials/` with `chmod 600`: +- `heygen-key.txt` (used via `HEYGEN_API_KEY` env var or CLI config) +- `elevenlabs-key.txt` (used via `xi-api-key` header) + +If keys are missing at session start, STOP and ask the user to paste them. Never proceed with a placeholder. + +## Defaults (from registry.json) +These are the canonical production defaults. Never hardcode — always read `registry.json`: +| Field | Value | +|---|---| +| HeyGen avatar | Graeham Watts — `9a3600b16f604059b6ab8b9a55e29ea9` | +| ElevenLabs voice | Graeham Watts Voice Clone — `Pa3vOYQHHpLJn1Tf7hnP` | +| ElevenLabs model | `eleven_multilingual_v2` | +| Aspect | `9:16` (vertical for Reels/Shorts/TikTok) | +| Resolution | `720p` (bump to `1080p` for listing videos) | + +The user has 70 personal "Graeham Watts" avatar looks. The renderer always uses the primary unless the script specifies an alternate look ID. + +## Registry refresh +Re-run `scripts/refresh_registry.py` when: +- A new avatar look is trained in HeyGen +- A new voice is added/cloned in ElevenLabs +- The user reports "avatar not found" + +## SSML compatibility notes (IMPORTANT) +ElevenLabs has **partial** SSML support — verified on `eleven_multilingual_v2`: + +| Tag | Supported | Behavior | +|---|---|---| +| `` | YES | Literal silence of the specified duration | +| `...` | YES (wrapper) | Required root for SSML mode | +| `...` | Silent pass-through | API accepts it but the rate/pitch attrs are NOT honored — the text inside is still synthesized, just without the prosody effect | +| ``, ``, `` | NOT supported | Tag is stripped; inner text is read normally | + +**What this means for v5.4 scripts:** `` tags give you deterministic pause timing (critical for pattern interrupts). `` tags are safe to keep in the script for human readability but don't rely on them for actual delivery changes. If you need slower/faster delivery, use ElevenLabs' `voice_settings.stability` and `style` parameters instead, or preprocess the text into multiple TTS calls with different settings and concatenate. + +For audio-tag-based delivery (laughs, sighs, etc.), use ElevenLabs' bracket syntax like `[laughs]`, `[whispers]` — NOT SSML. See `references/elevenlabs-audio-tags.md`. + +## Primary invocation +```bash +python3 scripts/full_render.py \ + --script /path/to/script.ssml.txt \ + --slug "ab1482-explainer-week3" \ + --resolution 720p \ + --aspect 9:16 +``` + +This wraps all four pipeline stages. Output file lands at `outputs/renders/.mp4` with a sibling `.meta.json` containing `video_id`, `audio_asset_id`, `duration`, `completed_at`, and a full `dashboards` block (HeyGen video page, HeyGen projects list, ElevenLabs history, ElevenLabs voice library). The poller also emits a single-line `RENDER_RESULT={...}` to stdout so webhook consumers and the v5.4 calendar button can surface those links without scraping log output. + +### Where renders + voices live + +| Asset | Dashboard URL | +|---|---| +| Finished video (this render) | `https://app.heygen.com/videos/` | +| All past videos | `https://app.heygen.com/projects` | +| TTS generation history | `https://elevenlabs.io/app/speech-synthesis/history` | +| Graeham voice clone | `https://elevenlabs.io/app/voice-library` | +| Local MP4 | `outputs/renders/.mp4` | +| Local metadata | `outputs/renders/.meta.json` | + +The v5.4 weekly calendar button reads the `dashboards` object returned by `webhook_handler.py /status/` and renders it as a row of click-through links next to the render status. The banner at the top of the Production Map tab also pings `/health` and always shows the HeyGen + ElevenLabs dashboard links — online or offline — so Graeham can always find the content. + +## Per-stage invocation (for debugging) +- `scripts/synthesize_voice.py --text-file script.txt --out audio.mp3` +- `scripts/upload_asset.py audio.mp3` → prints `asset_id` +- `scripts/render_video.py --audio-asset-id --title "..."` → prints `video_id` +- `scripts/poll_and_download.py --video-id --out outputs/renders/slug.mp4` + +## Error handling +- **401 from HeyGen:** key is invalid or expired → re-read `heygen-key.txt` +- **402 from HeyGen:** subscription credit exhausted → tell user plainly, don't retry +- **422 "voice is premade":** cannot use a premade voice with voice cloning settings — downgrade `voice_settings.style` to 0.0 +- **HeyGen video status "failed":** fetch `error.message`, common causes: audio file too long (>10 min), avatar not trained for aspect ratio, audio file corrupt +- **Timeout after 10 min polling:** video is likely stuck — submit again with `test: false` and `dimension` set to a smaller size (480x854) + +## Files in this skill +- `SKILL.md` — this file +- `scripts/full_render.py` — one-shot orchestrator +- `scripts/synthesize_voice.py` — ElevenLabs TTS +- `scripts/upload_asset.py` — HeyGen asset upload (CLI wrapper) +- `scripts/render_video.py` — HeyGen v3 video create +- `scripts/poll_and_download.py` — status polling + MP4 download +- `scripts/refresh_registry.py` — rebuild registry.json from live HeyGen + ElevenLabs data +- `references/registry.json` — avatar/voice IDs and defaults +- `references/webhook_handler.py` — local Flask handler for Auto-Render button +- `references/elevenlabs-audio-tags.md` — bracket-syntax reference + +## Hand-off contract +- **Upstream:** `content-creation-engine` writes a v5.4 script to `outputs/scripts/.ssml.txt` +- **Downstream:** the v5.4 weekly calendar Auto-Render button POSTs `{"slug": "..."}` to the local webhook → this skill runs `full_render.py` → MP4 lands in `outputs/renders/` + +## Cost guardrails +- ElevenLabs Creator tier = ~100k chars/month. A 60-second vertical = ~900 chars. Budget roughly 100 renders/mo before hitting the cap. +- HeyGen credits are per-video-minute. Test renders should use `resolution: "720p"` to conserve credits; only bump to 1080p for final deliverables. + +## Verification step (required after every render) +1. Download the MP4 and confirm `file output.mp4` reports a valid MPEG-4 container +2. Probe duration with `ffprobe` — should be within ±0.5s of the MP3 source +3. Open the signed URL in a browser and scrub to verify lip sync (eyeball test) + +If any verification step fails, mark the render failed and re-queue rather than shipping a broken file. + +## Sandbox allowlist gap (important) +Finished HeyGen MP4s are served from `files2.heygen.ai` on a signed CloudFront URL. This CDN host is NOT currently on the Cowork sandbox allowlist. In-sandbox downloads via `poll_and_download.py` will fail with proxy HTTP 403. Two fixes: +1. Add `files2.heygen.ai` and `resource2.heygen.ai` to the Cowork desktop allowlist (recommended). +2. Otherwise, run `webhook_handler.py` on the host Windows machine (outside the sandbox) — its network is unrestricted and downloads work. + +Verified test render that succeeded: video_id `f79ed46032f74759a1153ff7e06e33f6`, duration 7.77s, SSML source with `` tags honored. diff --git a/skills/heygen-elevenlabs-renderer/references/demo_calendar_with_button.html b/skills/heygen-elevenlabs-renderer/references/demo_calendar_with_button.html new file mode 100644 index 0000000..0061b26 --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/references/demo_calendar_with_button.html @@ -0,0 +1,136 @@ +<\!DOCTYPE html> + + + +v5.4 Auto-Render Demo + + + + +

v5.4 weekly calendar — Auto-Render Demo

+

This is the button pattern injected into every day card. One click runs the full pipeline on Graeham's local machine.

+ +
To test this locally: run python3 references/webhook_handler.py first, then open this file and click the button.
+ +<\!-- Sample Day Card --> +
+
+
MONDAY — Apr 13
+ BOFU +
+
AB 1482 Rent Cap Explainer — why your investor client should call now
+ +
+ Format: Instagram Reel · 60s · 9:16 · 1080p
+ Voice: Graeham Watts Voice Clone (ElevenLabs) → Graeham Watts avatar (HeyGen) +
+ +
<speak>Hey East Palo Alto homeowners <break time="0.4s"/> if you bought before 2021 <break time="0.3s"/> <prosody rate="slow">listen carefully.</prosody> <break time="0.5s"/> Your equity position is stronger than you think.</speak>
+ +
+ + + +
+
+ +
+
+
TUESDAY — Apr 14
+ TOFU +
+
East Palo Alto micro-neighborhoods tier list
+
<speak>I've sold in all four EPA micro-markets. <break time="0.3s"/> Here's my ranking, ordered by 2026 appreciation upside.</speak>
+
+ + + +
+
+ + + + diff --git a/skills/heygen-elevenlabs-renderer/references/elevenlabs-audio-tags.md b/skills/heygen-elevenlabs-renderer/references/elevenlabs-audio-tags.md new file mode 100644 index 0000000..18b935e --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/references/elevenlabs-audio-tags.md @@ -0,0 +1,31 @@ +# ElevenLabs Audio Tag Reference + +ElevenLabs does NOT support full W3C SSML. Use bracket-syntax audio tags for emotive delivery. + +## Supported bracket tags (eleven_multilingual_v2) + +| Tag | Effect | +|---|---| +| `[laughs]` | Short laugh insertion | +| `[chuckles]` | Softer laugh | +| `[sighs]` | Audible sigh | +| `[whispers]` | Switch to whispered delivery for following phrase | +| `[excited]` | Lift energy | +| `[sarcastic]` | Flatten tone | +| `[pause]` | ~0.4s silence | + +## SSML subset that IS supported + +| Tag | Effect | +|---|---| +| `...` | Root wrapper (optional, enables SSML mode) | +| `` | Exact silence duration (0.1s–3s) | + +## Not supported (silently stripped) + +- `` — tag accepted, text still read, but no rate change +- ``, ``, ``, ``, `` + +## v5.4 script compatibility + +v5.4 scripts use `` and `` for readability. Only `` delivers real audio changes. For true rate/pitch control, pre-split the script and synthesize each chunk with different `voice_settings.stability` values, then concatenate with ffmpeg. diff --git a/skills/heygen-elevenlabs-renderer/references/registry.json b/skills/heygen-elevenlabs-renderer/references/registry.json new file mode 100755 index 0000000..2f3c056 --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/references/registry.json @@ -0,0 +1,797 @@ +{ + "generated_at": "2026-04-13T22:12:05.883331+00:00", + "version": "1.0.0", + "defaults": { + "heygen_avatar_id": "9a3600b16f604059b6ab8b9a55e29ea9", + "heygen_avatar_name": "Graeham Watts", + "elevenlabs_voice_id": "Pa3vOYQHHpLJn1Tf7hnP", + "elevenlabs_voice_name": "Graeham Watts Voice Clone", + "elevenlabs_model": "eleven_multilingual_v2", + "video_resolution": { + "width": 1080, + "height": 1920 + }, + "video_aspect": "9:16" + }, + "heygen": { + "personal_avatars_count": 70, + "personal_avatars_full": [ + { + "id": "9a3600b16f604059b6ab8b9a55e29ea9", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/9a3600b16f604059b6ab8b9a55e29ea9/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "928f502843904b4cb82c116216ad92ff", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/928f502843904b4cb82c116216ad92ff/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "fbc5f122c70d4b5e9864fef13e83c78e", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/fbc5f122c70d4b5e9864fef13e83c78e/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "6b920eb820834257b32ce642dd9e6ce2", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/6b920eb820834257b32ce642dd9e6ce2/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "9219a2b401aa42d29a3ea38cde6d314c", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/9219a2b401aa42d29a3ea38cde6d314c/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "e2d77e0e0cb24e648832cfc63e916060", + "name": "Graeham Watts", + "preview": "https://resource2.heygen.ai/instant_avatar/avatar_iv_preview/e2d77e0e0cb24e648832cfc63e916060.webp", + "type": "avatar", + "premium": false + }, + { + "id": "2a84eac9ec51412fbc04ad74039cd840", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/2a84eac9ec51412fbc04ad74039cd840/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "53e3489955054f88825c28a0514d401f", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/53e3489955054f88825c28a0514d401f/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "1ea651dc1eff4f8fa0417dd047e30c75", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/1ea651dc1eff4f8fa0417dd047e30c75/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "f6fac25075424f59b99ab6e81b0f9d80", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/f6fac25075424f59b99ab6e81b0f9d80/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "73baa7f782be4fb6a242f4e8c87f203d", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/73baa7f782be4fb6a242f4e8c87f203d/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "0db5e52223d8483cb37c84192f18efc4", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/0db5e52223d8483cb37c84192f18efc4/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "db5d7c779ed540fe98a1bcff87ae6185", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/db5d7c779ed540fe98a1bcff87ae6185/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "668847fcfea24d07b4d9dbade97d2d7e", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/668847fcfea24d07b4d9dbade97d2d7e/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "b52fe48041144377b679be524a74fa8e", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/b52fe48041144377b679be524a74fa8e/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "8135edb40dc84d50b07ced8c7fde51a6", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/8135edb40dc84d50b07ced8c7fde51a6/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "d078208a437c44bb8ad2209d192882f3", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/d078208a437c44bb8ad2209d192882f3/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "e9ec3ca7b4fa4571b05ec31ef7ba4eb6", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/e9ec3ca7b4fa4571b05ec31ef7ba4eb6/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "cf3f5afc6a9f484f98013544dbd8c1c6", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/cf3f5afc6a9f484f98013544dbd8c1c6/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "2045b214375c412f9ce91de1ee5268c0", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/2045b214375c412f9ce91de1ee5268c0/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "068a91765ef74d67865a4ad973cfb129", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/068a91765ef74d67865a4ad973cfb129/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "d33951bac2ae4891ace9158951312520", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/d33951bac2ae4891ace9158951312520/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "a6c1a168a78e4592b49f80d9c85a0a68", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/a6c1a168a78e4592b49f80d9c85a0a68/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "451e446f6ba84d60b386992f0b1d7ee8", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/451e446f6ba84d60b386992f0b1d7ee8/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "1bbb5fd55f10441e841cea142966774f", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/1bbb5fd55f10441e841cea142966774f/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "c9f800ab155e46a8bfca861edf55be39", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/c9f800ab155e46a8bfca861edf55be39/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "3c81f584b8ee4eb69226c2be7e6cfbf9", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/3c81f584b8ee4eb69226c2be7e6cfbf9/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "019f5cd757a5472b89f98873c7570fe2", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/019f5cd757a5472b89f98873c7570fe2/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "774191aebdf54293acaf9ec87406b6ba", + "name": "Graeham Watts", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/f01a928e0c49489a92a5f2f46eb78b5e.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "d18923c8de2a4c70b138204be4877ca7", + "name": "Graeham Watts", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/ddb5a72bedeb488ebe9532b1186e5a8a.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "9706cd575e7f4e649a7d1bdaa78da85b", + "name": "Graeham Watts -- 113", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/d22ab1999a9e43068d1acbd7d82607d0.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "f85c5bf2f2fc4a3fae8b868c5985fad3", + "name": "Graeham Watts -- 138", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/64df4d32b28841ccbd590af1bd01cd38.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "49f2e94d9e964f95b453920d2e312f0d", + "name": "Graeham Watts -- 141", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/61874753ef4f4c668fec180e5c7e6993.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "159cd7b883724fdb9a51b97dec94df89", + "name": "Graeham Watts -- 142", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/88101dc52de8403f864173abfec0a78f.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "084ab8966ac1456682e069f4365acfca", + "name": "Graeham Watts -- 154", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/6cd21c39350040d09ec33ca143c77c43.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "9a3600b16f604059b6ab8b9a55e29ea9", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/9a3600b16f604059b6ab8b9a55e29ea9/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "928f502843904b4cb82c116216ad92ff", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/928f502843904b4cb82c116216ad92ff/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "fbc5f122c70d4b5e9864fef13e83c78e", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/fbc5f122c70d4b5e9864fef13e83c78e/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "6b920eb820834257b32ce642dd9e6ce2", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/6b920eb820834257b32ce642dd9e6ce2/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "9219a2b401aa42d29a3ea38cde6d314c", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/9219a2b401aa42d29a3ea38cde6d314c/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "e2d77e0e0cb24e648832cfc63e916060", + "name": "Graeham Watts", + "preview": "https://resource2.heygen.ai/instant_avatar/avatar_iv_preview/e2d77e0e0cb24e648832cfc63e916060.webp", + "type": "avatar", + "premium": false + }, + { + "id": "2a84eac9ec51412fbc04ad74039cd840", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/2a84eac9ec51412fbc04ad74039cd840/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "53e3489955054f88825c28a0514d401f", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/53e3489955054f88825c28a0514d401f/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "1ea651dc1eff4f8fa0417dd047e30c75", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/1ea651dc1eff4f8fa0417dd047e30c75/full/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "f6fac25075424f59b99ab6e81b0f9d80", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/f6fac25075424f59b99ab6e81b0f9d80/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "73baa7f782be4fb6a242f4e8c87f203d", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/73baa7f782be4fb6a242f4e8c87f203d/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "0db5e52223d8483cb37c84192f18efc4", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/0db5e52223d8483cb37c84192f18efc4/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "db5d7c779ed540fe98a1bcff87ae6185", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/db5d7c779ed540fe98a1bcff87ae6185/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "668847fcfea24d07b4d9dbade97d2d7e", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/668847fcfea24d07b4d9dbade97d2d7e/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "b52fe48041144377b679be524a74fa8e", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/b52fe48041144377b679be524a74fa8e/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "8135edb40dc84d50b07ced8c7fde51a6", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/8135edb40dc84d50b07ced8c7fde51a6/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "d078208a437c44bb8ad2209d192882f3", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/d078208a437c44bb8ad2209d192882f3/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "e9ec3ca7b4fa4571b05ec31ef7ba4eb6", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/e9ec3ca7b4fa4571b05ec31ef7ba4eb6/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "cf3f5afc6a9f484f98013544dbd8c1c6", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/cf3f5afc6a9f484f98013544dbd8c1c6/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "2045b214375c412f9ce91de1ee5268c0", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/2045b214375c412f9ce91de1ee5268c0/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "068a91765ef74d67865a4ad973cfb129", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/068a91765ef74d67865a4ad973cfb129/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "d33951bac2ae4891ace9158951312520", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/d33951bac2ae4891ace9158951312520/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "a6c1a168a78e4592b49f80d9c85a0a68", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/a6c1a168a78e4592b49f80d9c85a0a68/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "451e446f6ba84d60b386992f0b1d7ee8", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/451e446f6ba84d60b386992f0b1d7ee8/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "1bbb5fd55f10441e841cea142966774f", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/1bbb5fd55f10441e841cea142966774f/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "c9f800ab155e46a8bfca861edf55be39", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/c9f800ab155e46a8bfca861edf55be39/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "3c81f584b8ee4eb69226c2be7e6cfbf9", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/3c81f584b8ee4eb69226c2be7e6cfbf9/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "019f5cd757a5472b89f98873c7570fe2", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/avatar/v3/019f5cd757a5472b89f98873c7570fe2/half/2.2/preview_target.webp", + "type": "avatar", + "premium": false + }, + { + "id": "774191aebdf54293acaf9ec87406b6ba", + "name": "Graeham Watts", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/f01a928e0c49489a92a5f2f46eb78b5e.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "d18923c8de2a4c70b138204be4877ca7", + "name": "Graeham Watts", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/ddb5a72bedeb488ebe9532b1186e5a8a.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "9706cd575e7f4e649a7d1bdaa78da85b", + "name": "Graeham Watts -- 113", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/d22ab1999a9e43068d1acbd7d82607d0.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "f85c5bf2f2fc4a3fae8b868c5985fad3", + "name": "Graeham Watts -- 138", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/64df4d32b28841ccbd590af1bd01cd38.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "49f2e94d9e964f95b453920d2e312f0d", + "name": "Graeham Watts -- 141", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/61874753ef4f4c668fec180e5c7e6993.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "159cd7b883724fdb9a51b97dec94df89", + "name": "Graeham Watts -- 142", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/88101dc52de8403f864173abfec0a78f.jpg", + "type": "avatar", + "premium": false + }, + { + "id": "084ab8966ac1456682e069f4365acfca", + "name": "Graeham Watts -- 154", + "preview": "https://resource2.heygen.ai/best_frame_selection/candidates/6cd21c39350040d09ec33ca143c77c43.jpg", + "type": "avatar", + "premium": false + } + ], + "talking_photos_count": 3, + "talking_photos_sample": [ + { + "id": "1819420dda6d4368b068532121c84a85", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/talking_photo/1819420dda6d4368b068532121c84a85/1385189ab17c4364872001c45530fa59.WEBP?Expires=1776722861&Signature=lE8XL74PbLJKChmXF6qNo0bHyT-jLr5wvPJdPXBItn2lSUNqpXyz66MTNHE-Ao~VDhBAx-H2jnA5wDc84IY3oxpHriH86zoSOcgSLpVqsd3j8OwhAs~aMGHvwb~QVZogtj0RS7fHxJWPF-JfvBGkECOqwqHb0z-uRSjxbfzx4~cwK1pn2oVIghQOpSGH-thdB0JjgoAO9gjpIgnFMKhy9Xb~Te6zGIprT3KfCglz1rC~IimL4OmtUF-YLcmo9l6C~LZOrVFaD6FSVvdvNY-fOWVdmtVs5ER8gcFgHBI7g0UJKn3ZD943jbBlzRWi7IReyk5eQwCiv2kdhv~3osB~kQ__&Key-Pair-Id=K38HBHX5LX3X2H", + "type": "talking_photo" + }, + { + "id": "6b10dd96006e4d94af58f1edb31892c5", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/talking_photo/6b10dd96006e4d94af58f1edb31892c5/4a422bf4060548ff8c7989a84feaae45.webp?Expires=1776722862&Signature=acfFTp6s4Vkb1WR4hSMUgp7KwhqiSE~4qNvRQZkfadlFFBgDxqR~U6JdLkdip7OsgWbBAjZuyVjpYuzKbMLDPGqVSI1H751iWPADMmuXJJi75gkv~DPtNH8cG4lacogqM4bff25iDV3SBBBJYTA1VldM~6SRX2DdZYGAbJTUXkYlNjgtkFuC4ZzqNCpERt1EGNvTZMAXENBHFV3bhsmxqtNLTXwaK5CUFmLDv7K7GrM0Bh0J-uGBMA6dnnO4b4JwRlRzbhB9CUUpcM9Aks5aLxsIJ~mqBBIVKr1f2bTM1El0VaAAOzo6SV9K2pZndcETazDuGqdRXiF-VDewJNfeug__&Key-Pair-Id=K38HBHX5LX3X2H", + "type": "talking_photo" + }, + { + "id": "7d07ccc7b3dd4e0c89e15c7eefa620e2", + "name": "Graeham Watts", + "preview": "https://files2.heygen.ai/talking_photo/7d07ccc7b3dd4e0c89e15c7eefa620e2/b30a31214cbc4bac9d467d00209bf8a6.webp?Expires=1776722862&Signature=omC1qeD4mBoIsW4~Fmwss6FPJTYZxKAeOZPNl0bMP86D4cxa0JGlwXNWlCU1ukNWZaVJB2-Fr1GKPRj67k0pnN14BfJmprLGuZtP8kOSnrJrabS0bVOKeOGl0XzGI~ImbVxWV--tvO9590JqLtQ3TxamuXkS4HtDSXVAPbgRx~VH2QYx~Hn8zBec-rz1cpMBFoGoRgyM0IFhb0bCAfjWoXZuXGQwbWlrG55tmId6-77tN6z7zCL~3Li6wxfy~ILIgmMLTjZsaVVfAg9VeSkw0AOMaWYBE83QLg0kJoqLZVLdpra0YGETicVoIk~l3mpjIZcSPdLNFWhh18giNi5MUA__&Key-Pair-Id=K38HBHX5LX3X2H", + "type": "talking_photo" + } + ], + "library_voices_count": 2387 + }, + "elevenlabs": { + "graeham_voice_clone": { + "voice_id": "Pa3vOYQHHpLJn1Tf7hnP", + "name": "Graeham Watts Voice Clone", + "category": "professional", + "labels": { + "gender": "male", + "accent": "en-american", + "age": "middle-aged", + "language": "en" + } + }, + "total_voices": 29, + "by_use_case": { + "conversational": [ + { + "voice_id": "CwhRBWXzGAHq8TQ4Fs17", + "name": "Roger - Laid-Back, Casual, Resonant", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": "classy" + }, + { + "voice_id": "IKne3meq5aSn9XLyUdCD", + "name": "Charlie - Deep, Confident, Energetic", + "category": "premade", + "gender": "male", + "accent": "australian", + "descriptive": "hyped" + }, + { + "voice_id": "SAz9YHcvj6GT2YYXdXww", + "name": "River - Relaxed, Neutral, Informative", + "category": "premade", + "gender": "neutral", + "accent": "american", + "descriptive": "calm" + }, + { + "voice_id": "bIHbv24MWmeRgasZH58o", + "name": "Will - Relaxed Optimist", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": "chill" + }, + { + "voice_id": "cgSgspJ2msm6clMCkdW9", + "name": "Jessica - Playful, Bright, Warm", + "category": "premade", + "gender": "female", + "accent": "american", + "descriptive": "cute" + }, + { + "voice_id": "cjVigY5qzO86Huf0OWal", + "name": "Eric - Smooth, Trustworthy", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": "classy" + }, + { + "voice_id": "iP95p4xoKVk53GoZ742B", + "name": "Chris - Charming, Down-to-Earth", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": "casual" + } + ], + "entertainment_tv": [ + { + "voice_id": "EXAVITQu4vr4xnSDxMaL", + "name": "Sarah - Mature, Reassuring, Confident", + "category": "premade", + "gender": "female", + "accent": "american", + "descriptive": "professional" + } + ], + "social_media": [ + { + "voice_id": "FGY2WhTYpPnrIDTdsKH5", + "name": "Laura - Enthusiast, Quirky Attitude", + "category": "premade", + "gender": "female", + "accent": "american", + "descriptive": "sassy" + }, + { + "voice_id": "TX3LPaxmHKxFdv7VOQHJ", + "name": "Liam - Energetic, Social Media Creator", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": "confident" + }, + { + "voice_id": "nPczCjzI2devNBz1zQrb", + "name": "Brian - Deep, Resonant and Comforting", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": "classy" + }, + { + "voice_id": "pNInz6obpgDQGcFmaJgB", + "name": "Adam - Dominant, Firm", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": null + } + ], + "narrative_story": [ + { + "voice_id": "JBFqnCBsd6RMkjVDRZzb", + "name": "George - Warm, Captivating Storyteller", + "category": "premade", + "gender": "male", + "accent": "british", + "descriptive": "mature" + }, + { + "voice_id": "f5KRUAmxOzuhrrp8V3zv", + "name": "Titan \u2013 Young, Dramatic, Epic Storyteller/Movie Narrator", + "category": "professional", + "gender": "male", + "accent": "american", + "descriptive": "intense" + }, + { + "voice_id": "Dslrhjl3ZpzrctukrQSN", + "name": "Hey Its Brad - Clear Narrator for Documentary", + "category": "professional", + "gender": "male", + "accent": "american", + "descriptive": "casual" + } + ], + "characters_animation": [ + { + "voice_id": "N2lVS1w4EtoT3dr4eOWO", + "name": "Callum - Husky Trickster", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": null + }, + { + "voice_id": "SOYHLrjzK2X1ezoPC6cr", + "name": "Harry - Fierce Warrior", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": "rough" + }, + { + "voice_id": "weA4Q36twV5kwSaTEL0Q", + "name": "Ava - Female Robot or AI Assistant", + "category": "professional", + "gender": "female", + "accent": "american", + "descriptive": "robotic" + }, + { + "voice_id": "bwCXcoVxWNYMlC6Esa8u", + "name": "Matthew Schmitz - Anti-Hero, Villain, Rogue, Tough Guy", + "category": "professional", + "gender": "male", + "accent": "american", + "descriptive": "intense" + } + ], + "informative_educational": [ + { + "voice_id": "Xb7hH8MSUJpSbSDYk0k2", + "name": "Alice - Clear, Engaging Educator", + "category": "premade", + "gender": "female", + "accent": "british", + "descriptive": "professional" + }, + { + "voice_id": "XrExE9yKIg1WjnnlVkGX", + "name": "Matilda - Knowledgable, Professional", + "category": "premade", + "gender": "female", + "accent": "american", + "descriptive": "upbeat" + }, + { + "voice_id": "hpp4J3VqNfWAUOO0d1Us", + "name": "Bella - Professional, Bright, Warm", + "category": "premade", + "gender": "female", + "accent": "american", + "descriptive": "professional" + }, + { + "voice_id": "onwK4e9ZLuTAKqWW03F9", + "name": "Daniel - Steady Broadcaster", + "category": "premade", + "gender": "male", + "accent": "british", + "descriptive": "formal" + }, + { + "voice_id": "pFZP5JQG7iQjIQuC4Bku", + "name": "Lily - Velvety Actress", + "category": "premade", + "gender": "female", + "accent": "british", + "descriptive": "confident" + }, + { + "voice_id": "hfgNmTYYctMgJ7E2s6Vx", + "name": "Shaun - The Ultimate Narrator Voice", + "category": "professional", + "gender": "male", + "accent": "american", + "descriptive": "deep" + } + ], + "advertisement": [ + { + "voice_id": "pqHfZKP75CvOlQylNhV4", + "name": "Bill - Wise, Mature, Balanced", + "category": "premade", + "gender": "male", + "accent": "american", + "descriptive": "crisp" + } + ], + "other": [ + { + "voice_id": "X8NfXnmtiqkCTSR8Gcu6", + "name": "Funny Dracula", + "category": "generated", + "gender": null, + "accent": null, + "descriptive": null + }, + { + "voice_id": "K6Id9eFOS8sKqAqS5SeY", + "name": "Broadcast News Brian - TV Anchor", + "category": "professional", + "gender": "male", + "accent": "en-american", + "descriptive": null + }, + { + "voice_id": "Pa3vOYQHHpLJn1Tf7hnP", + "name": "Graeham Watts Voice Clone", + "category": "professional", + "gender": "male", + "accent": "en-american", + "descriptive": null + } + ] + } + } +} \ No newline at end of file diff --git a/skills/heygen-elevenlabs-renderer/references/v54_auto_render_button.html b/skills/heygen-elevenlabs-renderer/references/v54_auto_render_button.html new file mode 100644 index 0000000..2d46fc9 --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/references/v54_auto_render_button.html @@ -0,0 +1,163 @@ +<\!-- +v5.4 weekly calendar — Full Auto-Render Button Snippet (v6.2) +Placed under the ElevenLabs SSML block in every core asset derivative panel. +After render completes, surfaces direct links to: + - the MP4 on local disk + - the HeyGen video page (https://app.heygen.com/videos/) + - the ElevenLabs generation history & voice library + +v6.2 change: webhook_handler.py now returns a clean `dashboards` object — +no more regex-scraping stdout. Button also pre-populates dashboards before +render completes so the user always has a path to check progress manually. +--> + +<\!-- 1. BUTTON + STATUS + LINKS BLOCK --> +
+ + + +
+ +<\!-- 2. STYLES --> + + +<\!-- 3. JS (add once, globally, at bottom of ) --> + + +<\!-- 4. BANNER (place ONCE at top of Production Map tab) --> +
Checking Auto-Render status…
+ diff --git a/skills/heygen-elevenlabs-renderer/references/webhook_handler.py b/skills/heygen-elevenlabs-renderer/references/webhook_handler.py new file mode 100644 index 0000000..eafed06 --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/references/webhook_handler.py @@ -0,0 +1,110 @@ +#\!/usr/bin/env python3 +""" +Local webhook handler for the v5.4 weekly calendar "🚀 Full Auto-Render" button. + +Runs a Flask server on http://127.0.0.1:7788/render that accepts: + POST /render + {"slug": "ab1482-explainer", "script_path": "/abs/path/to/script.ssml.txt"} + +It spawns full_render.py in the background and immediately returns +{"queued": true, "job_id": ...}. Poll GET /status/ to check progress. + +The /status response now always includes a `dashboards` block with direct +links to HeyGen + ElevenLabs so the calendar UI can surface them the moment +a render finishes — no regex scraping required. + +Start it: + python3 webhook_handler.py + +Bind to your desktop only — never expose publicly (no auth on this endpoint). +""" +import json +import re +import subprocess +import threading +import uuid +from pathlib import Path + +try: + from flask import Flask, jsonify, request +except ImportError: + raise SystemExit("pip install flask --break-system-packages") + +SCRIPT_DIR = Path(__file__).parent.parent / "scripts" +JOBS = {} + +# Always surface these so the button shows them even before a job completes. +STATIC_DASHBOARDS = { + "heygen_projects": "https://app.heygen.com/projects", + "elevenlabs_history": "https://elevenlabs.io/app/speech-synthesis/history", + "elevenlabs_voice_library": "https://elevenlabs.io/app/voice-library", +} + +app = Flask(__name__) + + +def parse_render_result(stdout: str) -> dict: + """full_render.py / poll_and_download.py prints `RENDER_RESULT={...}`.""" + m = re.search(r"RENDER_RESULT=(\{.*\})", stdout or "") + if not m: + return {} + try: + return json.loads(m.group(1)) + except json.JSONDecodeError: + return {} + + +def run_job(job_id, slug, script_path): + JOBS[job_id]["status"] = "running" + try: + proc = subprocess.run( + ["python3", str(SCRIPT_DIR / "full_render.py"), + "--script", script_path, "--slug", slug], + capture_output=True, text=True, timeout=900, + ) + JOBS[job_id]["status"] = "done" if proc.returncode == 0 else "failed" + JOBS[job_id]["stdout"] = proc.stdout + JOBS[job_id]["stderr"] = proc.stderr + + result = parse_render_result(proc.stdout) + if result: + JOBS[job_id]["result"] = result + JOBS[job_id]["dashboards"] = { + **STATIC_DASHBOARDS, + "heygen_video_page": result.get("heygen_dashboard_url"), + "local_mp4": result.get("out"), + "meta_json": result.get("meta"), + } + except Exception as e: + JOBS[job_id]["status"] = "failed" + JOBS[job_id]["error"] = str(e) + + +@app.post("/render") +def render(): + payload = request.get_json(force=True) + slug = payload["slug"] + script_path = payload["script_path"] + job_id = str(uuid.uuid4()) + JOBS[job_id] = { + "status": "queued", + "slug": slug, + "dashboards": STATIC_DASHBOARDS, # visible immediately + } + threading.Thread(target=run_job, args=(job_id, slug, script_path), daemon=True).start() + return jsonify({"queued": True, "job_id": job_id, "dashboards": STATIC_DASHBOARDS}) + + +@app.get("/status/") +def status(job_id): + return jsonify(JOBS.get(job_id, {"error": "unknown job"})) + + +@app.get("/health") +def health(): + return jsonify({"ok": True, "dashboards": STATIC_DASHBOARDS}) + + +if __name__ == "__main__": + # 127.0.0.1 = desktop-only. Do not change to 0.0.0.0 without auth. + app.run(host="127.0.0.1", port=7788) diff --git a/skills/heygen-elevenlabs-renderer/scripts/full_render.py b/skills/heygen-elevenlabs-renderer/scripts/full_render.py new file mode 100644 index 0000000..5a48f60 --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/scripts/full_render.py @@ -0,0 +1,61 @@ +#\!/usr/bin/env python3 +""" +One-shot orchestrator: script → MP3 → asset → video → downloaded MP4. + +Usage: + python3 full_render.py --script path/to/script.ssml.txt --slug "my-video" +""" +import argparse +import subprocess +import sys +import time +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent + +def run(cmd, **kwargs): + print(f"+ {' '.join(cmd)}", flush=True) + return subprocess.run(cmd, check=True, text=True, capture_output=True, **kwargs) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--script", required=True, help="Path to v5.4 SSML script file") + p.add_argument("--slug", required=True) + p.add_argument("--aspect", default="9:16") + p.add_argument("--resolution", default="720p") + p.add_argument("--output-root", default=os.path.expanduser("~/Documents/Claude/Skills/.heygen-renders")) + args = p.parse_args() + + out_root = Path(args.output_root) + out_root.mkdir(parents=True, exist_ok=True) + mp3_path = out_root / f"{args.slug}.mp3" + mp4_path = out_root / f"{args.slug}.mp4" + + # 1. TTS + run(["python3", str(SCRIPT_DIR / "synthesize_voice.py"), + "--text-file", args.script, "--out", str(mp3_path)]) + + # 2. Upload + import json as _json + r = run(["python3", str(SCRIPT_DIR / "upload_asset.py"), str(mp3_path)]) + asset = _json.loads(r.stdout.strip().splitlines()[-1]) + asset_id = asset["asset_id"] + print(f"asset_id={asset_id}") + + # 3. Create video + r = run(["python3", str(SCRIPT_DIR / "render_video.py"), + "--audio-asset-id", asset_id, + "--title", args.slug, + "--aspect", args.aspect, + "--resolution", args.resolution]) + vid = _json.loads(r.stdout.strip().splitlines()[-1])["video_id"] + print(f"video_id={vid}") + + # 4. Poll + download + subprocess.run(["python3", str(SCRIPT_DIR / "poll_and_download.py"), + "--video-id", vid, "--out", str(mp4_path)], + check=True) + print(f"DONE: {mp4_path}") + +if __name__ == "__main__": + main() diff --git a/skills/heygen-elevenlabs-renderer/scripts/poll_and_download.py b/skills/heygen-elevenlabs-renderer/scripts/poll_and_download.py new file mode 100644 index 0000000..305b896 --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/scripts/poll_and_download.py @@ -0,0 +1,113 @@ +#\!/usr/bin/env python3 +""" +Poll HeyGen /v1/video_status.get until status == completed, then download MP4. + +Emits meta.json AND a single-line JSON to stdout with dashboard URLs so the +v5.4 calendar button can surface "where to find it" links (HeyGen video page, +ElevenLabs history page, local MP4) without regex-scraping stdout. + +Usage: + python3 poll_and_download.py --video-id --out outputs/renders/slug.mp4 +""" +import argparse +import json +import os +import sys +import time +import urllib.request +from pathlib import Path + +CRED_DIR = Path(os.environ.get( + "CLAUDE_CREDENTIALS_DIR", + os.path.expanduser("~/Documents/Claude/Skills/.heygen-credentials") +)) + +HEYGEN_DASHBOARD = "https://app.heygen.com/videos/{video_id}" +ELEVEN_HISTORY = "https://elevenlabs.io/app/speech-synthesis/history" +ELEVEN_VOICE_LIB = "https://elevenlabs.io/app/voice-library" + +def load_key(): + return (CRED_DIR / "heygen-key.txt").read_text().strip() + +def status(video_id): + req = urllib.request.Request( + f"https://api.heygen.com/v1/video_status.get?video_id={video_id}", + headers={"X-Api-Key": load_key()}, + ) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + +def download(url, out_path): + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + with urllib.request.urlopen(url, timeout=180) as r, open(out_path, "wb") as f: + while True: + chunk = r.read(65536) + if not chunk: + break + f.write(chunk) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--video-id", required=True) + p.add_argument("--out", required=True) + p.add_argument("--interval", type=int, default=15) + p.add_argument("--max-wait", type=int, default=600) + p.add_argument("--elevenlabs-voice-id", default=None, + help="Pin the voice_id used so the meta surfaces its history link") + args = p.parse_args() + + elapsed = 0 + s = None + while elapsed < args.max_wait: + resp = status(args.video_id) + data = resp.get("data", {}) + s = data.get("status") + print(f"[{elapsed}s] status={s}", flush=True) + if s == "completed": + url = data.get("video_url") + if not url: + sys.exit("completed but no video_url returned") + download(url, args.out) + + heygen_dashboard_url = HEYGEN_DASHBOARD.format(video_id=args.video_id) + meta = { + "video_id": args.video_id, + "video_url": url, + "thumbnail_url": data.get("thumbnail_url"), + "duration": data.get("duration"), + "completed_at": int(time.time()), + "local_mp4": str(Path(args.out).resolve()), + "dashboards": { + "heygen_video_page": heygen_dashboard_url, + "heygen_projects": "https://app.heygen.com/projects", + "elevenlabs_history": ELEVEN_HISTORY, + "elevenlabs_voice_library": ELEVEN_VOICE_LIB, + }, + } + if args.elevenlabs_voice_id: + meta["dashboards"]["elevenlabs_voice"] = ( + f"https://elevenlabs.io/app/voice-lab/share/{args.elevenlabs_voice_id}" + ) + + meta_path = Path(args.out).with_suffix(".meta.json") + meta_path.write_text(json.dumps(meta, indent=2)) + + # Single-line JSON the v5.4 button's JS can JSON.parse directly. + print("RENDER_RESULT=" + json.dumps({ + "status": "completed", + "video_id": args.video_id, + "out": str(Path(args.out).resolve()), + "meta": str(meta_path.resolve()), + "heygen_dashboard_url": heygen_dashboard_url, + "elevenlabs_history_url": ELEVEN_HISTORY, + })) + return + if s == "failed": + sys.exit(f"render failed: {data.get('error')}") + time.sleep(args.interval) + elapsed += args.interval + + sys.exit(f"timeout after {args.max_wait}s — video still {s}") + +if __name__ == "__main__": + main() diff --git a/skills/heygen-elevenlabs-renderer/scripts/refresh_registry.py b/skills/heygen-elevenlabs-renderer/scripts/refresh_registry.py new file mode 100755 index 0000000..d3a448b --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/scripts/refresh_registry.py @@ -0,0 +1,75 @@ +#\!/usr/bin/env python3 +""" +Rebuild references/registry.json by pulling live avatar + voice lists from +HeyGen (v2) and ElevenLabs. Run this anytime a new avatar or voice is trained. +""" +import json +import os +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +CRED_DIR = Path(os.environ.get( + "CLAUDE_CREDENTIALS_DIR", + os.path.expanduser("~/Documents/Claude/Skills/.heygen-credentials") +)) +OUT = Path(__file__).parent.parent / "references" / "registry.json" + +def get_json(url, headers): + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read()) + +def main(): + hg = (CRED_DIR / "heygen-key.txt").read_text().strip() + el = (CRED_DIR / "elevenlabs-key.txt").read_text().strip() + + hg_av = get_json("https://api.heygen.com/v2/avatars", {"X-Api-Key": hg}) + hg_vo = get_json("https://api.heygen.com/v2/voices", {"X-Api-Key": hg}) + el_vo = get_json("https://api.elevenlabs.io/v1/voices", {"xi-api-key": el}) + + avatars = hg_av.get("data", {}).get("avatars", []) + talking = hg_av.get("data", {}).get("talking_photos", []) + personal = [a for a in avatars if "graeham" in (a.get("avatar_name") or "").lower()] + + clone = None + for v in el_vo.get("voices", []): + if "graeham" in (v.get("name") or "").lower(): + clone = {"voice_id": v["voice_id"], "name": v["name"], + "category": v.get("category"), "labels": v.get("labels", {})} + break + + reg = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "version": "1.0.0", + "defaults": { + "heygen_avatar_id": personal[0]["avatar_id"] if personal else None, + "heygen_avatar_name": personal[0]["avatar_name"] if personal else None, + "elevenlabs_voice_id": clone["voice_id"] if clone else None, + "elevenlabs_voice_name": clone["name"] if clone else None, + "elevenlabs_model": "eleven_multilingual_v2", + "video_resolution": {"width": 1080, "height": 1920}, + "video_aspect": "9:16", + }, + "heygen": { + "personal_avatars_count": len(personal), + "personal_avatars_full": [ + {"id": a["avatar_id"], "name": a.get("avatar_name"), + "preview": a.get("preview_image_url"), + "premium": a.get("premium", False)} for a in personal + ], + }, + "elevenlabs": { + "graeham_voice_clone": clone, + "total_voices": len(el_vo.get("voices", [])), + }, + } + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(reg, indent=2)) + print(f"Wrote {OUT}") + print(f" Graeham avatars: {len(personal)}") + print(f" Voice clone: {clone['name'] if clone else 'MISSING'}") + +if __name__ == "__main__": + main() diff --git a/skills/heygen-elevenlabs-renderer/scripts/render_video.py b/skills/heygen-elevenlabs-renderer/scripts/render_video.py new file mode 100755 index 0000000..363d67c --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/scripts/render_video.py @@ -0,0 +1,86 @@ +#\!/usr/bin/env python3 +""" +Create a HeyGen avatar video using a pre-uploaded audio asset (voice_type: audio). +Uses HeyGen v3 /videos endpoint via the CLI. + +Usage: + python3 render_video.py --audio-asset-id --title "..." [--avatar-id ] +Prints: video_id on stdout +""" +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +CRED_DIR = Path(os.environ.get( + "CLAUDE_CREDENTIALS_DIR", + os.path.expanduser("~/Documents/Claude/Skills/.heygen-credentials") +)) +REGISTRY = Path(__file__).parent.parent / "references" / "registry.json" + +def load_key(): + return (CRED_DIR / "heygen-key.txt").read_text().strip() + +def load_defaults(): + if REGISTRY.exists(): + return json.loads(REGISTRY.read_text()).get("defaults", {}) + return { + "heygen_avatar_id": "9a3600b16f604059b6ab8b9a55e29ea9", + } + +def create(audio_asset_id, title, avatar_id=None, aspect="9:16", resolution="720p", engine="avatar_v"): + env = os.environ.copy() + env["HEYGEN_API_KEY"] = load_key() + env["PATH"] = os.path.expanduser("~/.local/bin") + ":" + env.get("PATH", "") + + avatar_id = avatar_id or load_defaults()["heygen_avatar_id"] + payload = { + "type": "avatar", + "avatar_id": avatar_id, + "audio_asset_id": audio_asset_id, + "aspect_ratio": aspect, + "resolution": resolution, + "title": title, + # Request Avatar V (best motion). Per HeyGen support: set engine="avatar_v" on the + # v3 /videos endpoint. If this avatar look doesn't support V, HeyGen AUTO-FALLS BACK + # to Avatar IV, so requesting it is always safe (no need to pre-check here). + # CAVEAT to verify once: this assumes `heygen video create` forwards the `engine` + # field through to POST /v3/videos. If the CLI strips unknown fields, switch this to + # a direct requests.post("https://api.heygen.com/v3/videos", ...). Confirm by logging + # the engine HeyGen reports on the finished video (see poll_and_download.py). + "engine": engine, + } + + result = subprocess.run( + ["heygen", "video", "create", "-d", "-"], + input=json.dumps(payload), env=env, + capture_output=True, text=True, timeout=60, + ) + if result.returncode != 0: + sys.exit(f"video create failed: {result.stderr}") + + for line in result.stdout.splitlines(): + line = line.strip() + if line.startswith("{"): + data = json.loads(line) + return data.get("data", {}).get("video_id") + sys.exit(f"could not parse create response: {result.stdout}") + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--audio-asset-id", required=True) + p.add_argument("--title", required=True) + p.add_argument("--avatar-id") + p.add_argument("--aspect", default="9:16", choices=["9:16", "16:9"]) + p.add_argument("--resolution", default="720p", choices=["720p", "1080p", "4k"]) + p.add_argument("--engine", default="avatar_v", choices=["avatar_v", "avatar_iv", "default"], + help="HeyGen render engine. avatar_v = best motion (auto-falls back to IV if unsupported).") + args = p.parse_args() + + vid = create(args.audio_asset_id, args.title, args.avatar_id, args.aspect, args.resolution, args.engine) + print(json.dumps({"video_id": vid})) + +if __name__ == "__main__": + main() diff --git a/skills/heygen-elevenlabs-renderer/scripts/synthesize_voice.py b/skills/heygen-elevenlabs-renderer/scripts/synthesize_voice.py new file mode 100644 index 0000000..3c8f0ef --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/scripts/synthesize_voice.py @@ -0,0 +1,83 @@ +#\!/usr/bin/env python3 +""" +ElevenLabs TTS — synthesize a v5.4 SSML script into MP3 using Graeham's voice clone. + +Usage: + python3 synthesize_voice.py --text-file script.ssml.txt --out audio.mp3 + python3 synthesize_voice.py --text "Hello world" --out audio.mp3 +""" +import argparse +import json +import os +import sys +import urllib.request +from pathlib import Path + +CRED_DIR = Path(os.environ.get( + "CLAUDE_CREDENTIALS_DIR", + os.path.expanduser("~/Documents/Claude/Skills/.heygen-credentials") +)) +REGISTRY = Path(__file__).parent.parent / "references" / "registry.json" + +def load_key(): + key_file = CRED_DIR / "elevenlabs-key.txt" + if not key_file.exists(): + sys.exit(f"ElevenLabs key not found at {key_file}. Paste it and retry.") + return key_file.read_text().strip() + +def load_defaults(): + if REGISTRY.exists(): + return json.loads(REGISTRY.read_text()).get("defaults", {}) + return { + "elevenlabs_voice_id": "Pa3vOYQHHpLJn1Tf7hnP", + "elevenlabs_model": "eleven_multilingual_v2", + } + +def synthesize(text, out_path, voice_id=None, model=None, stability=0.5, similarity=0.75, style=0.0): + key = load_key() + defaults = load_defaults() + voice_id = voice_id or defaults["elevenlabs_voice_id"] + model = model or defaults.get("elevenlabs_model", "eleven_multilingual_v2") + + url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}?output_format=mp3_44100_128" + body = json.dumps({ + "text": text, + "model_id": model, + "voice_settings": { + "stability": stability, + "similarity_boost": similarity, + "style": style, + "use_speaker_boost": True, + }, + }).encode() + + req = urllib.request.Request(url, data=body, method="POST", headers={ + "xi-api-key": key, + "Content-Type": "application/json", + "Accept": "audio/mpeg", + }) + with urllib.request.urlopen(req, timeout=120) as resp: + data = resp.read() + + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + Path(out_path).write_bytes(data) + return len(data) + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--text-file", help="Path to SSML text file") + p.add_argument("--text", help="Inline text (for quick tests)") + p.add_argument("--out", required=True, help="Output MP3 path") + p.add_argument("--voice-id", help="Override voice_id") + p.add_argument("--model", help="Override model_id") + args = p.parse_args() + + if not (args.text_file or args.text): + sys.exit("Provide --text-file or --text") + + text = Path(args.text_file).read_text() if args.text_file else args.text + n = synthesize(text, args.out, voice_id=args.voice_id, model=args.model) + print(f"Wrote {args.out} ({n} bytes)") + +if __name__ == "__main__": + main() diff --git a/skills/heygen-elevenlabs-renderer/scripts/upload_asset.py b/skills/heygen-elevenlabs-renderer/scripts/upload_asset.py new file mode 100755 index 0000000..9262917 --- /dev/null +++ b/skills/heygen-elevenlabs-renderer/scripts/upload_asset.py @@ -0,0 +1,54 @@ +#\!/usr/bin/env python3 +""" +HeyGen asset uploader — wraps `heygen asset create --file` for audio MP3s. +CLI is required because api.heygen.com upload endpoint may not be fully allowlisted +in the sandbox; CLI handles the upload path under the hood. + +Usage: + python3 upload_asset.py path/to/audio.mp3 +Prints: asset_id on stdout +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +CRED_DIR = Path(os.environ.get( + "CLAUDE_CREDENTIALS_DIR", + os.path.expanduser("~/Documents/Claude/Skills/.heygen-credentials") +)) + +def load_key(): + return (CRED_DIR / "heygen-key.txt").read_text().strip() + +def upload(path): + env = os.environ.copy() + env["HEYGEN_API_KEY"] = load_key() + env["PATH"] = os.path.expanduser("~/.local/bin") + ":" + env.get("PATH", "") + + result = subprocess.run( + ["heygen", "asset", "create", "--file", str(path)], + env=env, capture_output=True, text=True, timeout=120, + ) + # CLI writes posthog warnings to stderr — ignore + if result.returncode != 0: + sys.exit(f"asset upload failed: {result.stderr}") + + # Find the JSON line in stdout + for line in result.stdout.splitlines(): + line = line.strip() + if line.startswith("{"): + data = json.loads(line) + asset = data.get("data", {}) + return asset.get("asset_id"), asset.get("url") + sys.exit(f"could not parse asset response: {result.stdout}") + +def main(): + if len(sys.argv) != 2: + sys.exit("Usage: upload_asset.py ") + asset_id, url = upload(sys.argv[1]) + print(json.dumps({"asset_id": asset_id, "url": url})) + +if __name__ == "__main__": + main() diff --git a/skills/heygen-video/SKILL.md b/skills/heygen-video/SKILL.md new file mode 100644 index 0000000..82fbf8b --- /dev/null +++ b/skills/heygen-video/SKILL.md @@ -0,0 +1,134 @@ +--- +name: heygen-video +description: Generate HeyGen avatar videos of Graeham Watts using his trained digital twin and photo-avatar looks. Use ANY time the user mentions HeyGen video, avatar video, AI avatar, talking head video, video of me, video of Graeham, listing intro video, market update video, personalized video message, buyer Q&A video, seller update video, "make me a video", "render a HeyGen video", "create an avatar video", or turning a script into a finished video with Graeham's face and voice. Also trigger on follow-ups like "check on that HeyGen video", "is the video ready", "download the avatar video", or when resuming a previously submitted HeyGen job via video_id. This skill is the CORRECT CHOICE for any HeyGen output — do not use video-creator (slideshow) or remotion-video (React) for HeyGen avatar work. Pair with content-creation-engine when the user has a topic but no script yet. +--- + +# HeyGen Video — Graeham Watts + +Generate HeyGen avatar videos using Graeham's trained looks. v1 of this skill is a single-brand (Graeham only) workflow — PropOS brand avatars are not trained yet and can be added to `references/avatars.md` when they are. + +## When this skill fires + +- "Make a HeyGen video of me saying X" +- "Render a listing intro video with Graeham's avatar" +- "Turn this script into a HeyGen avatar video" +- "Create a market update video" +- "Generate an avatar video: + + diff --git a/skills/skill-creator/eval-viewer/generate_review.py b/skills/skill-creator/eval-viewer/generate_review.py new file mode 100755 index 0000000..7fa5978 --- /dev/null +++ b/skills/skill-creator/eval-viewer/generate_review.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""Generate and serve a review page for eval results. + +Reads the workspace directory, discovers runs (directories with outputs/), +embeds all output data into a self-contained HTML page, and serves it via +a tiny HTTP server. Feedback auto-saves to feedback.json in the workspace. + +Usage: + python generate_review.py [--port PORT] [--skill-name NAME] + python generate_review.py --previous-feedback /path/to/old/feedback.json + +No dependencies beyond the Python stdlib are required. +""" + +import argparse +import base64 +import json +import mimetypes +import os +import re +import signal +import subprocess +import sys +import time +import webbrowser +from functools import partial +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path + +# Files to exclude from output listings +METADATA_FILES = {"transcript.md", "user_notes.md", "metrics.json"} + +# Extensions we render as inline text +TEXT_EXTENSIONS = { + ".txt", ".md", ".json", ".csv", ".py", ".js", ".ts", ".tsx", ".jsx", + ".yaml", ".yml", ".xml", ".html", ".css", ".sh", ".rb", ".go", ".rs", + ".java", ".c", ".cpp", ".h", ".hpp", ".sql", ".r", ".toml", +} + +# Extensions we render as inline images +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"} + +# MIME type overrides for common types +MIME_OVERRIDES = { + ".svg": "image/svg+xml", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +} + + +def get_mime_type(path: Path) -> str: + ext = path.suffix.lower() + if ext in MIME_OVERRIDES: + return MIME_OVERRIDES[ext] + mime, _ = mimetypes.guess_type(str(path)) + return mime or "application/octet-stream" + + +def find_runs(workspace: Path) -> list[dict]: + """Recursively find directories that contain an outputs/ subdirectory.""" + runs: list[dict] = [] + _find_runs_recursive(workspace, workspace, runs) + runs.sort(key=lambda r: (r.get("eval_id", float("inf")), r["id"])) + return runs + + +def _find_runs_recursive(root: Path, current: Path, runs: list[dict]) -> None: + if not current.is_dir(): + return + + outputs_dir = current / "outputs" + if outputs_dir.is_dir(): + run = build_run(root, current) + if run: + runs.append(run) + return + + skip = {"node_modules", ".git", "__pycache__", "skill", "inputs"} + for child in sorted(current.iterdir()): + if child.is_dir() and child.name not in skip: + _find_runs_recursive(root, child, runs) + + +def build_run(root: Path, run_dir: Path) -> dict | None: + """Build a run dict with prompt, outputs, and grading data.""" + prompt = "" + eval_id = None + + # Try eval_metadata.json + for candidate in [run_dir / "eval_metadata.json", run_dir.parent / "eval_metadata.json"]: + if candidate.exists(): + try: + metadata = json.loads(candidate.read_text()) + prompt = metadata.get("prompt", "") + eval_id = metadata.get("eval_id") + except (json.JSONDecodeError, OSError): + pass + if prompt: + break + + # Fall back to transcript.md + if not prompt: + for candidate in [run_dir / "transcript.md", run_dir / "outputs" / "transcript.md"]: + if candidate.exists(): + try: + text = candidate.read_text() + match = re.search(r"## Eval Prompt\n\n([\s\S]*?)(?=\n##|$)", text) + if match: + prompt = match.group(1).strip() + except OSError: + pass + if prompt: + break + + if not prompt: + prompt = "(No prompt found)" + + run_id = str(run_dir.relative_to(root)).replace("/", "-").replace("\\", "-") + + # Collect output files + outputs_dir = run_dir / "outputs" + output_files: list[dict] = [] + if outputs_dir.is_dir(): + for f in sorted(outputs_dir.iterdir()): + if f.is_file() and f.name not in METADATA_FILES: + output_files.append(embed_file(f)) + + # Load grading if present + grading = None + for candidate in [run_dir / "grading.json", run_dir.parent / "grading.json"]: + if candidate.exists(): + try: + grading = json.loads(candidate.read_text()) + except (json.JSONDecodeError, OSError): + pass + if grading: + break + + return { + "id": run_id, + "prompt": prompt, + "eval_id": eval_id, + "outputs": output_files, + "grading": grading, + } + + +def embed_file(path: Path) -> dict: + """Read a file and return an embedded representation.""" + ext = path.suffix.lower() + mime = get_mime_type(path) + + if ext in TEXT_EXTENSIONS: + try: + content = path.read_text(errors="replace") + except OSError: + content = "(Error reading file)" + return { + "name": path.name, + "type": "text", + "content": content, + } + elif ext in IMAGE_EXTENSIONS: + try: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + except OSError: + return {"name": path.name, "type": "error", "content": "(Error reading file)"} + return { + "name": path.name, + "type": "image", + "mime": mime, + "data_uri": f"data:{mime};base64,{b64}", + } + elif ext == ".pdf": + try: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + except OSError: + return {"name": path.name, "type": "error", "content": "(Error reading file)"} + return { + "name": path.name, + "type": "pdf", + "data_uri": f"data:{mime};base64,{b64}", + } + elif ext == ".xlsx": + try: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + except OSError: + return {"name": path.name, "type": "error", "content": "(Error reading file)"} + return { + "name": path.name, + "type": "xlsx", + "data_b64": b64, + } + else: + # Binary / unknown — base64 download link + try: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + except OSError: + return {"name": path.name, "type": "error", "content": "(Error reading file)"} + return { + "name": path.name, + "type": "binary", + "mime": mime, + "data_uri": f"data:{mime};base64,{b64}", + } + + +def load_previous_iteration(workspace: Path) -> dict[str, dict]: + """Load previous iteration's feedback and outputs. + + Returns a map of run_id -> {"feedback": str, "outputs": list[dict]}. + """ + result: dict[str, dict] = {} + + # Load feedback + feedback_map: dict[str, str] = {} + feedback_path = workspace / "feedback.json" + if feedback_path.exists(): + try: + data = json.loads(feedback_path.read_text()) + feedback_map = { + r["run_id"]: r["feedback"] + for r in data.get("reviews", []) + if r.get("feedback", "").strip() + } + except (json.JSONDecodeError, OSError, KeyError): + pass + + # Load runs (to get outputs) + prev_runs = find_runs(workspace) + for run in prev_runs: + result[run["id"]] = { + "feedback": feedback_map.get(run["id"], ""), + "outputs": run.get("outputs", []), + } + + # Also add feedback for run_ids that had feedback but no matching run + for run_id, fb in feedback_map.items(): + if run_id not in result: + result[run_id] = {"feedback": fb, "outputs": []} + + return result + + +def generate_html( + runs: list[dict], + skill_name: str, + previous: dict[str, dict] | None = None, + benchmark: dict | None = None, +) -> str: + """Generate the complete standalone HTML page with embedded data.""" + template_path = Path(__file__).parent / "viewer.html" + template = template_path.read_text() + + # Build previous_feedback and previous_outputs maps for the template + previous_feedback: dict[str, str] = {} + previous_outputs: dict[str, list[dict]] = {} + if previous: + for run_id, data in previous.items(): + if data.get("feedback"): + previous_feedback[run_id] = data["feedback"] + if data.get("outputs"): + previous_outputs[run_id] = data["outputs"] + + embedded = { + "skill_name": skill_name, + "runs": runs, + "previous_feedback": previous_feedback, + "previous_outputs": previous_outputs, + } + if benchmark: + embedded["benchmark"] = benchmark + + data_json = json.dumps(embedded) + + return template.replace("/*__EMBEDDED_DATA__*/", f"const EMBEDDED_DATA = {data_json};") + + +# --------------------------------------------------------------------------- +# HTTP server (stdlib only, zero dependencies) +# --------------------------------------------------------------------------- + +def _kill_port(port: int) -> None: + """Kill any process listening on the given port.""" + try: + result = subprocess.run( + ["lsof", "-ti", f":{port}"], + capture_output=True, text=True, timeout=5, + ) + for pid_str in result.stdout.strip().split("\n"): + if pid_str.strip(): + try: + os.kill(int(pid_str.strip()), signal.SIGTERM) + except (ProcessLookupError, ValueError): + pass + if result.stdout.strip(): + time.sleep(0.5) + except subprocess.TimeoutExpired: + pass + except FileNotFoundError: + print("Note: lsof not found, cannot check if port is in use", file=sys.stderr) + +class ReviewHandler(BaseHTTPRequestHandler): + """Serves the review HTML and handles feedback saves. + + Regenerates the HTML on each page load so that refreshing the browser + picks up new eval outputs without restarting the server. + """ + + def __init__( + self, + workspace: Path, + skill_name: str, + feedback_path: Path, + previous: dict[str, dict], + benchmark_path: Path | None, + *args, + **kwargs, + ): + self.workspace = workspace + self.skill_name = skill_name + self.feedback_path = feedback_path + self.previous = previous + self.benchmark_path = benchmark_path + super().__init__(*args, **kwargs) + + def do_GET(self) -> None: + if self.path == "/" or self.path == "/index.html": + # Regenerate HTML on each request (re-scans workspace for new outputs) + runs = find_runs(self.workspace) + benchmark = None + if self.benchmark_path and self.benchmark_path.exists(): + try: + benchmark = json.loads(self.benchmark_path.read_text()) + except (json.JSONDecodeError, OSError): + pass + html = generate_html(runs, self.skill_name, self.previous, benchmark) + content = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + elif self.path == "/api/feedback": + data = b"{}" + if self.feedback_path.exists(): + data = self.feedback_path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + else: + self.send_error(404) + + def do_POST(self) -> None: + if self.path == "/api/feedback": + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + data = json.loads(body) + if not isinstance(data, dict) or "reviews" not in data: + raise ValueError("Expected JSON object with 'reviews' key") + self.feedback_path.write_text(json.dumps(data, indent=2) + "\n") + resp = b'{"ok":true}' + self.send_response(200) + except (json.JSONDecodeError, OSError, ValueError) as e: + resp = json.dumps({"error": str(e)}).encode() + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(resp))) + self.end_headers() + self.wfile.write(resp) + else: + self.send_error(404) + + def log_message(self, format: str, *args: object) -> None: + # Suppress request logging to keep terminal clean + pass + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate and serve eval review") + parser.add_argument("workspace", type=Path, help="Path to workspace directory") + parser.add_argument("--port", "-p", type=int, default=3117, help="Server port (default: 3117)") + parser.add_argument("--skill-name", "-n", type=str, default=None, help="Skill name for header") + parser.add_argument( + "--previous-workspace", type=Path, default=None, + help="Path to previous iteration's workspace (shows old outputs and feedback as context)", + ) + parser.add_argument( + "--benchmark", type=Path, default=None, + help="Path to benchmark.json to show in the Benchmark tab", + ) + parser.add_argument( + "--static", "-s", type=Path, default=None, + help="Write standalone HTML to this path instead of starting a server", + ) + args = parser.parse_args() + + workspace = args.workspace.resolve() + if not workspace.is_dir(): + print(f"Error: {workspace} is not a directory", file=sys.stderr) + sys.exit(1) + + runs = find_runs(workspace) + if not runs: + print(f"No runs found in {workspace}", file=sys.stderr) + sys.exit(1) + + skill_name = args.skill_name or workspace.name.replace("-workspace", "") + feedback_path = workspace / "feedback.json" + + previous: dict[str, dict] = {} + if args.previous_workspace: + previous = load_previous_iteration(args.previous_workspace.resolve()) + + benchmark_path = args.benchmark.resolve() if args.benchmark else None + benchmark = None + if benchmark_path and benchmark_path.exists(): + try: + benchmark = json.loads(benchmark_path.read_text()) + except (json.JSONDecodeError, OSError): + pass + + if args.static: + html = generate_html(runs, skill_name, previous, benchmark) + args.static.parent.mkdir(parents=True, exist_ok=True) + args.static.write_text(html) + print(f"\n Static viewer written to: {args.static}\n") + sys.exit(0) + + # Kill any existing process on the target port + port = args.port + _kill_port(port) + handler = partial(ReviewHandler, workspace, skill_name, feedback_path, previous, benchmark_path) + try: + server = HTTPServer(("127.0.0.1", port), handler) + except OSError: + # Port still in use after kill attempt — find a free one + server = HTTPServer(("127.0.0.1", 0), handler) + port = server.server_address[1] + + url = f"http://localhost:{port}" + print(f"\n Eval Viewer") + print(f" ─────────────────────────────────") + print(f" URL: {url}") + print(f" Workspace: {workspace}") + print(f" Feedback: {feedback_path}") + if previous: + print(f" Previous: {args.previous_workspace} ({len(previous)} runs)") + if benchmark_path: + print(f" Benchmark: {benchmark_path}") + print(f"\n Press Ctrl+C to stop.\n") + + webbrowser.open(url) + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/eval-viewer/viewer.html b/skills/skill-creator/eval-viewer/viewer.html new file mode 100755 index 0000000..6d8e963 --- /dev/null +++ b/skills/skill-creator/eval-viewer/viewer.html @@ -0,0 +1,1325 @@ + + + + + + Eval Review + + + + + + + +
+
+
+

Eval Review:

+
Review each output and leave feedback below. Navigate with arrow keys or buttons. When done, copy feedback and paste into Claude Code.
+
+
+
+ + + + + +
+
+ +
+
Prompt
+
+
+
+
+ + +
+
Output
+
+
No output files found
+
+
+ + + + + + + + +
+
Your Feedback
+
+ + + +
+
+
+ + +
+ + +
+
+
No benchmark data available. Run a benchmark to see quantitative results here.
+
+
+
+ + +
+
+

Review Complete

+

Your feedback has been saved. Go back to your Claude Code session and tell Claude you're done reviewing.

+
+ +
+
+
+ + +
+ + + + diff --git a/skills/skill-creator/references/schemas.md b/skills/skill-creator/references/schemas.md new file mode 100755 index 0000000..b6eeaa2 --- /dev/null +++ b/skills/skill-creator/references/schemas.md @@ -0,0 +1,430 @@ +# JSON Schemas + +This document defines the JSON schemas used by skill-creator. + +--- + +## evals.json + +Defines the evals for a skill. Located at `evals/evals.json` within the skill directory. + +```json +{ + "skill_name": "example-skill", + "evals": [ + { + "id": 1, + "prompt": "User's example prompt", + "expected_output": "Description of expected result", + "files": ["evals/files/sample1.pdf"], + "expectations": [ + "The output includes X", + "The skill used script Y" + ] + } + ] +} +``` + +**Fields:** +- `skill_name`: Name matching the skill's frontmatter +- `evals[].id`: Unique integer identifier +- `evals[].prompt`: The task to execute +- `evals[].expected_output`: Human-readable description of success +- `evals[].files`: Optional list of input file paths (relative to skill root) +- `evals[].expectations`: List of verifiable statements + +--- + +## history.json + +Tracks version progression in Improve mode. Located at workspace root. + +```json +{ + "started_at": "2026-01-15T10:30:00Z", + "skill_name": "pdf", + "current_best": "v2", + "iterations": [ + { + "version": "v0", + "parent": null, + "expectation_pass_rate": 0.65, + "grading_result": "baseline", + "is_current_best": false + }, + { + "version": "v1", + "parent": "v0", + "expectation_pass_rate": 0.75, + "grading_result": "won", + "is_current_best": false + }, + { + "version": "v2", + "parent": "v1", + "expectation_pass_rate": 0.85, + "grading_result": "won", + "is_current_best": true + } + ] +} +``` + +**Fields:** +- `started_at`: ISO timestamp of when improvement started +- `skill_name`: Name of the skill being improved +- `current_best`: Version identifier of the best performer +- `iterations[].version`: Version identifier (v0, v1, ...) +- `iterations[].parent`: Parent version this was derived from +- `iterations[].expectation_pass_rate`: Pass rate from grading +- `iterations[].grading_result`: "baseline", "won", "lost", or "tie" +- `iterations[].is_current_best`: Whether this is the current best version + +--- + +## grading.json + +Output from the grader agent. Located at `/grading.json`. + +```json +{ + "expectations": [ + { + "text": "The output includes the name 'John Smith'", + "passed": true, + "evidence": "Found in transcript Step 3: 'Extracted names: John Smith, Sarah Johnson'" + }, + { + "text": "The spreadsheet has a SUM formula in cell B10", + "passed": false, + "evidence": "No spreadsheet was created. The output was a text file." + } + ], + "summary": { + "passed": 2, + "failed": 1, + "total": 3, + "pass_rate": 0.67 + }, + "execution_metrics": { + "tool_calls": { + "Read": 5, + "Write": 2, + "Bash": 8 + }, + "total_tool_calls": 15, + "total_steps": 6, + "errors_encountered": 0, + "output_chars": 12450, + "transcript_chars": 3200 + }, + "timing": { + "executor_duration_seconds": 165.0, + "grader_duration_seconds": 26.0, + "total_duration_seconds": 191.0 + }, + "claims": [ + { + "claim": "The form has 12 fillable fields", + "type": "factual", + "verified": true, + "evidence": "Counted 12 fields in field_info.json" + } + ], + "user_notes_summary": { + "uncertainties": ["Used 2023 data, may be stale"], + "needs_review": [], + "workarounds": ["Fell back to text overlay for non-fillable fields"] + }, + "eval_feedback": { + "suggestions": [ + { + "assertion": "The output includes the name 'John Smith'", + "reason": "A hallucinated document that mentions the name would also pass" + } + ], + "overall": "Assertions check presence but not correctness." + } +} +``` + +**Fields:** +- `expectations[]`: Graded expectations with evidence +- `summary`: Aggregate pass/fail counts +- `execution_metrics`: Tool usage and output size (from executor's metrics.json) +- `timing`: Wall clock timing (from timing.json) +- `claims`: Extracted and verified claims from the output +- `user_notes_summary`: Issues flagged by the executor +- `eval_feedback`: (optional) Improvement suggestions for the evals, only present when the grader identifies issues worth raising + +--- + +## metrics.json + +Output from the executor agent. Located at `/outputs/metrics.json`. + +```json +{ + "tool_calls": { + "Read": 5, + "Write": 2, + "Bash": 8, + "Edit": 1, + "Glob": 2, + "Grep": 0 + }, + "total_tool_calls": 18, + "total_steps": 6, + "files_created": ["filled_form.pdf", "field_values.json"], + "errors_encountered": 0, + "output_chars": 12450, + "transcript_chars": 3200 +} +``` + +**Fields:** +- `tool_calls`: Count per tool type +- `total_tool_calls`: Sum of all tool calls +- `total_steps`: Number of major execution steps +- `files_created`: List of output files created +- `errors_encountered`: Number of errors during execution +- `output_chars`: Total character count of output files +- `transcript_chars`: Character count of transcript + +--- + +## timing.json + +Wall clock timing for a run. Located at `/timing.json`. + +**How to capture:** When a subagent task completes, the task notification includes `total_tokens` and `duration_ms`. Save these immediately — they are not persisted anywhere else and cannot be recovered after the fact. + +```json +{ + "total_tokens": 84852, + "duration_ms": 23332, + "total_duration_seconds": 23.3, + "executor_start": "2026-01-15T10:30:00Z", + "executor_end": "2026-01-15T10:32:45Z", + "executor_duration_seconds": 165.0, + "grader_start": "2026-01-15T10:32:46Z", + "grader_end": "2026-01-15T10:33:12Z", + "grader_duration_seconds": 26.0 +} +``` + +--- + +## benchmark.json + +Output from Benchmark mode. Located at `benchmarks//benchmark.json`. + +```json +{ + "metadata": { + "skill_name": "pdf", + "skill_path": "/path/to/pdf", + "executor_model": "claude-sonnet-4-20250514", + "analyzer_model": "most-capable-model", + "timestamp": "2026-01-15T10:30:00Z", + "evals_run": [1, 2, 3], + "runs_per_configuration": 3 + }, + + "runs": [ + { + "eval_id": 1, + "eval_name": "Ocean", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 0.85, + "passed": 6, + "failed": 1, + "total": 7, + "time_seconds": 42.5, + "tokens": 3800, + "tool_calls": 18, + "errors": 0 + }, + "expectations": [ + {"text": "...", "passed": true, "evidence": "..."} + ], + "notes": [ + "Used 2023 data, may be stale", + "Fell back to text overlay for non-fillable fields" + ] + } + ], + + "run_summary": { + "with_skill": { + "pass_rate": {"mean": 0.85, "stddev": 0.05, "min": 0.80, "max": 0.90}, + "time_seconds": {"mean": 45.0, "stddev": 12.0, "min": 32.0, "max": 58.0}, + "tokens": {"mean": 3800, "stddev": 400, "min": 3200, "max": 4100} + }, + "without_skill": { + "pass_rate": {"mean": 0.35, "stddev": 0.08, "min": 0.28, "max": 0.45}, + "time_seconds": {"mean": 32.0, "stddev": 8.0, "min": 24.0, "max": 42.0}, + "tokens": {"mean": 2100, "stddev": 300, "min": 1800, "max": 2500} + }, + "delta": { + "pass_rate": "+0.50", + "time_seconds": "+13.0", + "tokens": "+1700" + } + }, + + "notes": [ + "Assertion 'Output is a PDF file' passes 100% in both configurations - may not differentiate skill value", + "Eval 3 shows high variance (50% ± 40%) - may be flaky or model-dependent", + "Without-skill runs consistently fail on table extraction expectations", + "Skill adds 13s average execution time but improves pass rate by 50%" + ] +} +``` + +**Fields:** +- `metadata`: Information about the benchmark run + - `skill_name`: Name of the skill + - `timestamp`: When the benchmark was run + - `evals_run`: List of eval names or IDs + - `runs_per_configuration`: Number of runs per config (e.g. 3) +- `runs[]`: Individual run results + - `eval_id`: Numeric eval identifier + - `eval_name`: Human-readable eval name (used as section header in the viewer) + - `configuration`: Must be `"with_skill"` or `"without_skill"` (the viewer uses this exact string for grouping and color coding) + - `run_number`: Integer run number (1, 2, 3...) + - `result`: Nested object with `pass_rate`, `passed`, `total`, `time_seconds`, `tokens`, `errors` +- `run_summary`: Statistical aggregates per configuration + - `with_skill` / `without_skill`: Each contains `pass_rate`, `time_seconds`, `tokens` objects with `mean` and `stddev` fields + - `delta`: Difference strings like `"+0.50"`, `"+13.0"`, `"+1700"` +- `notes`: Freeform observations from the analyzer + +**Important:** The viewer reads these field names exactly. Using `config` instead of `configuration`, or putting `pass_rate` at the top level of a run instead of nested under `result`, will cause the viewer to show empty/zero values. Always reference this schema when generating benchmark.json manually. + +--- + +## comparison.json + +Output from blind comparator. Located at `/comparison-N.json`. + +```json +{ + "winner": "A", + "reasoning": "Output A provides a complete solution with proper formatting and all required fields. Output B is missing the date field and has formatting inconsistencies.", + "rubric": { + "A": { + "content": { + "correctness": 5, + "completeness": 5, + "accuracy": 4 + }, + "structure": { + "organization": 4, + "formatting": 5, + "usability": 4 + }, + "content_score": 4.7, + "structure_score": 4.3, + "overall_score": 9.0 + }, + "B": { + "content": { + "correctness": 3, + "completeness": 2, + "accuracy": 3 + }, + "structure": { + "organization": 3, + "formatting": 2, + "usability": 3 + }, + "content_score": 2.7, + "structure_score": 2.7, + "overall_score": 5.4 + } + }, + "output_quality": { + "A": { + "score": 9, + "strengths": ["Complete solution", "Well-formatted", "All fields present"], + "weaknesses": ["Minor style inconsistency in header"] + }, + "B": { + "score": 5, + "strengths": ["Readable output", "Correct basic structure"], + "weaknesses": ["Missing date field", "Formatting inconsistencies", "Partial data extraction"] + } + }, + "expectation_results": { + "A": { + "passed": 4, + "total": 5, + "pass_rate": 0.80, + "details": [ + {"text": "Output includes name", "passed": true} + ] + }, + "B": { + "passed": 3, + "total": 5, + "pass_rate": 0.60, + "details": [ + {"text": "Output includes name", "passed": true} + ] + } + } +} +``` + +--- + +## analysis.json + +Output from post-hoc analyzer. Located at `/analysis.json`. + +```json +{ + "comparison_summary": { + "winner": "A", + "winner_skill": "path/to/winner/skill", + "loser_skill": "path/to/loser/skill", + "comparator_reasoning": "Brief summary of why comparator chose winner" + }, + "winner_strengths": [ + "Clear step-by-step instructions for handling multi-page documents", + "Included validation script that caught formatting errors" + ], + "loser_weaknesses": [ + "Vague instruction 'process the document appropriately' led to inconsistent behavior", + "No script for validation, agent had to improvise" + ], + "instruction_following": { + "winner": { + "score": 9, + "issues": ["Minor: skipped optional logging step"] + }, + "loser": { + "score": 6, + "issues": [ + "Did not use the skill's formatting template", + "Invented own approach instead of following step 3" + ] + } + }, + "improvement_suggestions": [ + { + "priority": "high", + "category": "instructions", + "suggestion": "Replace 'process the document appropriately' with explicit steps", + "expected_impact": "Would eliminate ambiguity that caused inconsistent behavior" + } + ], + "transcript_insights": { + "winner_execution_pattern": "Read skill -> Followed 5-step process -> Used validation script", + "loser_execution_pattern": "Read skill -> Unclear on approach -> Tried 3 different methods" + } +} +``` diff --git a/skills/skill-creator/scripts/__init__.py b/skills/skill-creator/scripts/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/skills/skill-creator/scripts/aggregate_benchmark.py b/skills/skill-creator/scripts/aggregate_benchmark.py new file mode 100755 index 0000000..3e66e8c --- /dev/null +++ b/skills/skill-creator/scripts/aggregate_benchmark.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Aggregate individual run results into benchmark summary statistics. + +Reads grading.json files from run directories and produces: +- run_summary with mean, stddev, min, max for each metric +- delta between with_skill and without_skill configurations + +Usage: + python aggregate_benchmark.py + +Example: + python aggregate_benchmark.py benchmarks/2026-01-15T10-30-00/ + +The script supports two directory layouts: + + Workspace layout (from skill-creator iterations): + / + └── eval-N/ + ├── with_skill/ + │ ├── run-1/grading.json + │ └── run-2/grading.json + └── without_skill/ + ├── run-1/grading.json + └── run-2/grading.json + + Legacy layout (with runs/ subdirectory): + / + └── runs/ + └── eval-N/ + ├── with_skill/ + │ └── run-1/grading.json + └── without_skill/ + └── run-1/grading.json +""" + +import argparse +import json +import math +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def calculate_stats(values: list[float]) -> dict: + """Calculate mean, stddev, min, max for a list of values.""" + if not values: + return {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0} + + n = len(values) + mean = sum(values) / n + + if n > 1: + variance = sum((x - mean) ** 2 for x in values) / (n - 1) + stddev = math.sqrt(variance) + else: + stddev = 0.0 + + return { + "mean": round(mean, 4), + "stddev": round(stddev, 4), + "min": round(min(values), 4), + "max": round(max(values), 4) + } + + +def load_run_results(benchmark_dir: Path) -> dict: + """ + Load all run results from a benchmark directory. + + Returns dict keyed by config name (e.g. "with_skill"/"without_skill", + or "new_skill"/"old_skill"), each containing a list of run results. + """ + # Support both layouts: eval dirs directly under benchmark_dir, or under runs/ + runs_dir = benchmark_dir / "runs" + if runs_dir.exists(): + search_dir = runs_dir + elif list(benchmark_dir.glob("eval-*")): + search_dir = benchmark_dir + else: + print(f"No eval directories found in {benchmark_dir} or {benchmark_dir / 'runs'}") + return {} + + results: dict[str, list] = {} + + for eval_idx, eval_dir in enumerate(sorted(search_dir.glob("eval-*"))): + metadata_path = eval_dir / "eval_metadata.json" + if metadata_path.exists(): + try: + with open(metadata_path) as mf: + eval_id = json.load(mf).get("eval_id", eval_idx) + except (json.JSONDecodeError, OSError): + eval_id = eval_idx + else: + try: + eval_id = int(eval_dir.name.split("-")[1]) + except ValueError: + eval_id = eval_idx + + # Discover config directories dynamically rather than hardcoding names + for config_dir in sorted(eval_dir.iterdir()): + if not config_dir.is_dir(): + continue + # Skip non-config directories (inputs, outputs, etc.) + if not list(config_dir.glob("run-*")): + continue + config = config_dir.name + if config not in results: + results[config] = [] + + for run_dir in sorted(config_dir.glob("run-*")): + run_number = int(run_dir.name.split("-")[1]) + grading_file = run_dir / "grading.json" + + if not grading_file.exists(): + print(f"Warning: grading.json not found in {run_dir}") + continue + + try: + with open(grading_file) as f: + grading = json.load(f) + except json.JSONDecodeError as e: + print(f"Warning: Invalid JSON in {grading_file}: {e}") + continue + + # Extract metrics + result = { + "eval_id": eval_id, + "run_number": run_number, + "pass_rate": grading.get("summary", {}).get("pass_rate", 0.0), + "passed": grading.get("summary", {}).get("passed", 0), + "failed": grading.get("summary", {}).get("failed", 0), + "total": grading.get("summary", {}).get("total", 0), + } + + # Extract timing — check grading.json first, then sibling timing.json + timing = grading.get("timing", {}) + result["time_seconds"] = timing.get("total_duration_seconds", 0.0) + timing_file = run_dir / "timing.json" + if result["time_seconds"] == 0.0 and timing_file.exists(): + try: + with open(timing_file) as tf: + timing_data = json.load(tf) + result["time_seconds"] = timing_data.get("total_duration_seconds", 0.0) + result["tokens"] = timing_data.get("total_tokens", 0) + except json.JSONDecodeError: + pass + + # Extract metrics if available + metrics = grading.get("execution_metrics", {}) + result["tool_calls"] = metrics.get("total_tool_calls", 0) + if not result.get("tokens"): + result["tokens"] = metrics.get("output_chars", 0) + result["errors"] = metrics.get("errors_encountered", 0) + + # Extract expectations — viewer requires fields: text, passed, evidence + raw_expectations = grading.get("expectations", []) + for exp in raw_expectations: + if "text" not in exp or "passed" not in exp: + print(f"Warning: expectation in {grading_file} missing required fields (text, passed, evidence): {exp}") + result["expectations"] = raw_expectations + + # Extract notes from user_notes_summary + notes_summary = grading.get("user_notes_summary", {}) + notes = [] + notes.extend(notes_summary.get("uncertainties", [])) + notes.extend(notes_summary.get("needs_review", [])) + notes.extend(notes_summary.get("workarounds", [])) + result["notes"] = notes + + results[config].append(result) + + return results + + +def aggregate_results(results: dict) -> dict: + """ + Aggregate run results into summary statistics. + + Returns run_summary with stats for each configuration and delta. + """ + run_summary = {} + configs = list(results.keys()) + + for config in configs: + runs = results.get(config, []) + + if not runs: + run_summary[config] = { + "pass_rate": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, + "time_seconds": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, + "tokens": {"mean": 0, "stddev": 0, "min": 0, "max": 0} + } + continue + + pass_rates = [r["pass_rate"] for r in runs] + times = [r["time_seconds"] for r in runs] + tokens = [r.get("tokens", 0) for r in runs] + + run_summary[config] = { + "pass_rate": calculate_stats(pass_rates), + "time_seconds": calculate_stats(times), + "tokens": calculate_stats(tokens) + } + + # Calculate delta between the first two configs (if two exist) + if len(configs) >= 2: + primary = run_summary.get(configs[0], {}) + baseline = run_summary.get(configs[1], {}) + else: + primary = run_summary.get(configs[0], {}) if configs else {} + baseline = {} + + delta_pass_rate = primary.get("pass_rate", {}).get("mean", 0) - baseline.get("pass_rate", {}).get("mean", 0) + delta_time = primary.get("time_seconds", {}).get("mean", 0) - baseline.get("time_seconds", {}).get("mean", 0) + delta_tokens = primary.get("tokens", {}).get("mean", 0) - baseline.get("tokens", {}).get("mean", 0) + + run_summary["delta"] = { + "pass_rate": f"{delta_pass_rate:+.2f}", + "time_seconds": f"{delta_time:+.1f}", + "tokens": f"{delta_tokens:+.0f}" + } + + return run_summary + + +def generate_benchmark(benchmark_dir: Path, skill_name: str = "", skill_path: str = "") -> dict: + """ + Generate complete benchmark.json from run results. + """ + results = load_run_results(benchmark_dir) + run_summary = aggregate_results(results) + + # Build runs array for benchmark.json + runs = [] + for config in results: + for result in results[config]: + runs.append({ + "eval_id": result["eval_id"], + "configuration": config, + "run_number": result["run_number"], + "result": { + "pass_rate": result["pass_rate"], + "passed": result["passed"], + "failed": result["failed"], + "total": result["total"], + "time_seconds": result["time_seconds"], + "tokens": result.get("tokens", 0), + "tool_calls": result.get("tool_calls", 0), + "errors": result.get("errors", 0) + }, + "expectations": result["expectations"], + "notes": result["notes"] + }) + + # Determine eval IDs from results + eval_ids = sorted(set( + r["eval_id"] + for config in results.values() + for r in config + )) + + benchmark = { + "metadata": { + "skill_name": skill_name or "", + "skill_path": skill_path or "", + "executor_model": "", + "analyzer_model": "", + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "evals_run": eval_ids, + "runs_per_configuration": 3 + }, + "runs": runs, + "run_summary": run_summary, + "notes": [] # To be filled by analyzer + } + + return benchmark + + +def generate_markdown(benchmark: dict) -> str: + """Generate human-readable benchmark.md from benchmark data.""" + metadata = benchmark["metadata"] + run_summary = benchmark["run_summary"] + + # Determine config names (excluding "delta") + configs = [k for k in run_summary if k != "delta"] + config_a = configs[0] if len(configs) >= 1 else "config_a" + config_b = configs[1] if len(configs) >= 2 else "config_b" + label_a = config_a.replace("_", " ").title() + label_b = config_b.replace("_", " ").title() + + lines = [ + f"# Skill Benchmark: {metadata['skill_name']}", + "", + f"**Model**: {metadata['executor_model']}", + f"**Date**: {metadata['timestamp']}", + f"**Evals**: {', '.join(map(str, metadata['evals_run']))} ({metadata['runs_per_configuration']} runs each per configuration)", + "", + "## Summary", + "", + f"| Metric | {label_a} | {label_b} | Delta |", + "|--------|------------|---------------|-------|", + ] + + a_summary = run_summary.get(config_a, {}) + b_summary = run_summary.get(config_b, {}) + delta = run_summary.get("delta", {}) + + # Format pass rate + a_pr = a_summary.get("pass_rate", {}) + b_pr = b_summary.get("pass_rate", {}) + lines.append(f"| Pass Rate | {a_pr.get('mean', 0)*100:.0f}% ± {a_pr.get('stddev', 0)*100:.0f}% | {b_pr.get('mean', 0)*100:.0f}% ± {b_pr.get('stddev', 0)*100:.0f}% | {delta.get('pass_rate', '—')} |") + + # Format time + a_time = a_summary.get("time_seconds", {}) + b_time = b_summary.get("time_seconds", {}) + lines.append(f"| Time | {a_time.get('mean', 0):.1f}s ± {a_time.get('stddev', 0):.1f}s | {b_time.get('mean', 0):.1f}s ± {b_time.get('stddev', 0):.1f}s | {delta.get('time_seconds', '—')}s |") + + # Format tokens + a_tokens = a_summary.get("tokens", {}) + b_tokens = b_summary.get("tokens", {}) + lines.append(f"| Tokens | {a_tokens.get('mean', 0):.0f} ± {a_tokens.get('stddev', 0):.0f} | {b_tokens.get('mean', 0):.0f} ± {b_tokens.get('stddev', 0):.0f} | {delta.get('tokens', '—')} |") + + # Notes section + if benchmark.get("notes"): + lines.extend([ + "", + "## Notes", + "" + ]) + for note in benchmark["notes"]: + lines.append(f"- {note}") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Aggregate benchmark run results into summary statistics" + ) + parser.add_argument( + "benchmark_dir", + type=Path, + help="Path to the benchmark directory" + ) + parser.add_argument( + "--skill-name", + default="", + help="Name of the skill being benchmarked" + ) + parser.add_argument( + "--skill-path", + default="", + help="Path to the skill being benchmarked" + ) + parser.add_argument( + "--output", "-o", + type=Path, + help="Output path for benchmark.json (default: /benchmark.json)" + ) + + args = parser.parse_args() + + if not args.benchmark_dir.exists(): + print(f"Directory not found: {args.benchmark_dir}") + sys.exit(1) + + # Generate benchmark + benchmark = generate_benchmark(args.benchmark_dir, args.skill_name, args.skill_path) + + # Determine output paths + output_json = args.output or (args.benchmark_dir / "benchmark.json") + output_md = output_json.with_suffix(".md") + + # Write benchmark.json + with open(output_json, "w") as f: + json.dump(benchmark, f, indent=2) + print(f"Generated: {output_json}") + + # Write benchmark.md + markdown = generate_markdown(benchmark) + with open(output_md, "w") as f: + f.write(markdown) + print(f"Generated: {output_md}") + + # Print summary + run_summary = benchmark["run_summary"] + configs = [k for k in run_summary if k != "delta"] + delta = run_summary.get("delta", {}) + + print(f"\nSummary:") + for config in configs: + pr = run_summary[config]["pass_rate"]["mean"] + label = config.replace("_", " ").title() + print(f" {label}: {pr*100:.1f}% pass rate") + print(f" Delta: {delta.get('pass_rate', '—')}") + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/generate_report.py b/skills/skill-creator/scripts/generate_report.py new file mode 100755 index 0000000..959e30a --- /dev/null +++ b/skills/skill-creator/scripts/generate_report.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Generate an HTML report from run_loop.py output. + +Takes the JSON output from run_loop.py and generates a visual HTML report +showing each description attempt with check/x for each test case. +Distinguishes between train and test queries. +""" + +import argparse +import html +import json +import sys +from pathlib import Path + + +def generate_html(data: dict, auto_refresh: bool = False, skill_name: str = "") -> str: + """Generate HTML report from loop output data. If auto_refresh is True, adds a meta refresh tag.""" + history = data.get("history", []) + holdout = data.get("holdout", 0) + title_prefix = html.escape(skill_name + " \u2014 ") if skill_name else "" + + # Get all unique queries from train and test sets, with should_trigger info + train_queries: list[dict] = [] + test_queries: list[dict] = [] + if history: + for r in history[0].get("train_results", history[0].get("results", [])): + train_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)}) + if history[0].get("test_results"): + for r in history[0].get("test_results", []): + test_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)}) + + refresh_tag = ' \n' if auto_refresh else "" + + html_parts = [""" + + + +""" + refresh_tag + """ """ + title_prefix + """Skill Description Optimization + + + + + + +

""" + title_prefix + """Skill Description Optimization

+
+ Optimizing your skill's description. This page updates automatically as Claude tests different versions of your skill's description. Each row is an iteration — a new description attempt. The columns show test queries: green checkmarks mean the skill triggered correctly (or correctly didn't trigger), red crosses mean it got it wrong. The "Train" score shows performance on queries used to improve the description; the "Test" score shows performance on held-out queries the optimizer hasn't seen. When it's done, Claude will apply the best-performing description to your skill. +
+"""] + + # Summary section + best_test_score = data.get('best_test_score') + best_train_score = data.get('best_train_score') + html_parts.append(f""" +
+

Original: {html.escape(data.get('original_description', 'N/A'))}

+

Best: {html.escape(data.get('best_description', 'N/A'))}

+

Best Score: {data.get('best_score', 'N/A')} {'(test)' if best_test_score else '(train)'}

+

Iterations: {data.get('iterations_run', 0)} | Train: {data.get('train_size', '?')} | Test: {data.get('test_size', '?')}

+
+""") + + # Legend + html_parts.append(""" +
+ Query columns: + Should trigger + Should NOT trigger + Train + Test +
+""") + + # Table header + html_parts.append(""" +
+ + + + + + + +""") + + # Add column headers for train queries + for qinfo in train_queries: + polarity = "positive-col" if qinfo["should_trigger"] else "negative-col" + html_parts.append(f' \n') + + # Add column headers for test queries (different color) + for qinfo in test_queries: + polarity = "positive-col" if qinfo["should_trigger"] else "negative-col" + html_parts.append(f' \n') + + html_parts.append(""" + + +""") + + # Find best iteration for highlighting + if test_queries: + best_iter = max(history, key=lambda h: h.get("test_passed") or 0).get("iteration") + else: + best_iter = max(history, key=lambda h: h.get("train_passed", h.get("passed", 0))).get("iteration") + + # Add rows for each iteration + for h in history: + iteration = h.get("iteration", "?") + train_passed = h.get("train_passed", h.get("passed", 0)) + train_total = h.get("train_total", h.get("total", 0)) + test_passed = h.get("test_passed") + test_total = h.get("test_total") + description = h.get("description", "") + train_results = h.get("train_results", h.get("results", [])) + test_results = h.get("test_results", []) + + # Create lookups for results by query + train_by_query = {r["query"]: r for r in train_results} + test_by_query = {r["query"]: r for r in test_results} if test_results else {} + + # Compute aggregate correct/total runs across all retries + def aggregate_runs(results: list[dict]) -> tuple[int, int]: + correct = 0 + total = 0 + for r in results: + runs = r.get("runs", 0) + triggers = r.get("triggers", 0) + total += runs + if r.get("should_trigger", True): + correct += triggers + else: + correct += runs - triggers + return correct, total + + train_correct, train_runs = aggregate_runs(train_results) + test_correct, test_runs = aggregate_runs(test_results) + + # Determine score classes + def score_class(correct: int, total: int) -> str: + if total > 0: + ratio = correct / total + if ratio >= 0.8: + return "score-good" + elif ratio >= 0.5: + return "score-ok" + return "score-bad" + + train_class = score_class(train_correct, train_runs) + test_class = score_class(test_correct, test_runs) + + row_class = "best-row" if iteration == best_iter else "" + + html_parts.append(f""" + + + + +""") + + # Add result for each train query + for qinfo in train_queries: + r = train_by_query.get(qinfo["query"], {}) + did_pass = r.get("pass", False) + triggers = r.get("triggers", 0) + runs = r.get("runs", 0) + + icon = "✓" if did_pass else "✗" + css_class = "pass" if did_pass else "fail" + + html_parts.append(f' \n') + + # Add result for each test query (with different background) + for qinfo in test_queries: + r = test_by_query.get(qinfo["query"], {}) + did_pass = r.get("pass", False) + triggers = r.get("triggers", 0) + runs = r.get("runs", 0) + + icon = "✓" if did_pass else "✗" + css_class = "pass" if did_pass else "fail" + + html_parts.append(f' \n') + + html_parts.append(" \n") + + html_parts.append(""" +
IterTrainTestDescription{html.escape(qinfo["query"])}{html.escape(qinfo["query"])}
{iteration}{train_correct}/{train_runs}{test_correct}/{test_runs}{html.escape(description)}{icon}{triggers}/{runs}{icon}{triggers}/{runs}
+
+""") + + html_parts.append(""" + + +""") + + return "".join(html_parts) + + +def main(): + parser = argparse.ArgumentParser(description="Generate HTML report from run_loop output") + parser.add_argument("input", help="Path to JSON output from run_loop.py (or - for stdin)") + parser.add_argument("-o", "--output", default=None, help="Output HTML file (default: stdout)") + parser.add_argument("--skill-name", default="", help="Skill name to include in the report title") + args = parser.parse_args() + + if args.input == "-": + data = json.load(sys.stdin) + else: + data = json.loads(Path(args.input).read_text()) + + html_output = generate_html(data, skill_name=args.skill_name) + + if args.output: + Path(args.output).write_text(html_output) + print(f"Report written to {args.output}", file=sys.stderr) + else: + print(html_output) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/improve_description.py b/skills/skill-creator/scripts/improve_description.py new file mode 100755 index 0000000..06bcec7 --- /dev/null +++ b/skills/skill-creator/scripts/improve_description.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Improve a skill description based on eval results. + +Takes eval results (from run_eval.py) and generates an improved description +by calling `claude -p` as a subprocess (same auth pattern as run_eval.py — +uses the session's Claude Code auth, no separate ANTHROPIC_API_KEY needed). +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +from scripts.utils import parse_skill_md + + +def _call_claude(prompt: str, model: str | None, timeout: int = 300) -> str: + """Run `claude -p` with the prompt on stdin and return the text response. + + Prompt goes over stdin (not argv) because it embeds the full SKILL.md + body and can easily exceed comfortable argv length. + """ + cmd = ["claude", "-p", "--output-format", "text"] + if model: + cmd.extend(["--model", model]) + + # Remove CLAUDECODE env var to allow nesting claude -p inside a + # Claude Code session. The guard is for interactive terminal conflicts; + # programmatic subprocess usage is safe. Same pattern as run_eval.py. + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + + result = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + env=env, + timeout=timeout, + ) + if result.returncode != 0: + raise RuntimeError( + f"claude -p exited {result.returncode}\nstderr: {result.stderr}" + ) + return result.stdout + + +def improve_description( + skill_name: str, + skill_content: str, + current_description: str, + eval_results: dict, + history: list[dict], + model: str, + test_results: dict | None = None, + log_dir: Path | None = None, + iteration: int | None = None, +) -> str: + """Call Claude to improve the description based on eval results.""" + failed_triggers = [ + r for r in eval_results["results"] + if r["should_trigger"] and not r["pass"] + ] + false_triggers = [ + r for r in eval_results["results"] + if not r["should_trigger"] and not r["pass"] + ] + + # Build scores summary + train_score = f"{eval_results['summary']['passed']}/{eval_results['summary']['total']}" + if test_results: + test_score = f"{test_results['summary']['passed']}/{test_results['summary']['total']}" + scores_summary = f"Train: {train_score}, Test: {test_score}" + else: + scores_summary = f"Train: {train_score}" + + prompt = f"""You are optimizing a skill description for a Claude Code skill called "{skill_name}". A "skill" is sort of like a prompt, but with progressive disclosure -- there's a title and description that Claude sees when deciding whether to use the skill, and then if it does use the skill, it reads the .md file which has lots more details and potentially links to other resources in the skill folder like helper files and scripts and additional documentation or examples. + +The description appears in Claude's "available_skills" list. When a user sends a query, Claude decides whether to invoke the skill based solely on the title and on this description. Your goal is to write a description that triggers for relevant queries, and doesn't trigger for irrelevant ones. + +Here's the current description: + +"{current_description}" + + +Current scores ({scores_summary}): + +""" + if failed_triggers: + prompt += "FAILED TO TRIGGER (should have triggered but didn't):\n" + for r in failed_triggers: + prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n' + prompt += "\n" + + if false_triggers: + prompt += "FALSE TRIGGERS (triggered but shouldn't have):\n" + for r in false_triggers: + prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n' + prompt += "\n" + + if history: + prompt += "PREVIOUS ATTEMPTS (do NOT repeat these — try something structurally different):\n\n" + for h in history: + train_s = f"{h.get('train_passed', h.get('passed', 0))}/{h.get('train_total', h.get('total', 0))}" + test_s = f"{h.get('test_passed', '?')}/{h.get('test_total', '?')}" if h.get('test_passed') is not None else None + score_str = f"train={train_s}" + (f", test={test_s}" if test_s else "") + prompt += f'\n' + prompt += f'Description: "{h["description"]}"\n' + if "results" in h: + prompt += "Train results:\n" + for r in h["results"]: + status = "PASS" if r["pass"] else "FAIL" + prompt += f' [{status}] "{r["query"][:80]}" (triggered {r["triggers"]}/{r["runs"]})\n' + if h.get("note"): + prompt += f'Note: {h["note"]}\n' + prompt += "\n\n" + + prompt += f""" + +Skill content (for context on what the skill does): + +{skill_content} + + +Based on the failures, write a new and improved description that is more likely to trigger correctly. When I say "based on the failures", it's a bit of a tricky line to walk because we don't want to overfit to the specific cases you're seeing. So what I DON'T want you to do is produce an ever-expanding list of specific queries that this skill should or shouldn't trigger for. Instead, try to generalize from the failures to broader categories of user intent and situations where this skill would be useful or not useful. The reason for this is twofold: + +1. Avoid overfitting +2. The list might get loooong and it's injected into ALL queries and there might be a lot of skills, so we don't want to blow too much space on any given description. + +Concretely, your description should not be more than about 100-200 words, even if that comes at the cost of accuracy. There is a hard limit of 1024 characters — descriptions over that will be truncated, so stay comfortably under it. + +Here are some tips that we've found to work well in writing these descriptions: +- The skill should be phrased in the imperative -- "Use this skill for" rather than "this skill does" +- The skill description should focus on the user's intent, what they are trying to achieve, vs. the implementation details of how the skill works. +- The description competes with other skills for Claude's attention — make it distinctive and immediately recognizable. +- If you're getting lots of failures after repeated attempts, change things up. Try different sentence structures or wordings. + +I'd encourage you to be creative and mix up the style in different iterations since you'll have multiple opportunities to try different approaches and we'll just grab the highest-scoring one at the end. + +Please respond with only the new description text in tags, nothing else.""" + + text = _call_claude(prompt, model) + + match = re.search(r"(.*?)", text, re.DOTALL) + description = match.group(1).strip().strip('"') if match else text.strip().strip('"') + + transcript: dict = { + "iteration": iteration, + "prompt": prompt, + "response": text, + "parsed_description": description, + "char_count": len(description), + "over_limit": len(description) > 1024, + } + + # Safety net: the prompt already states the 1024-char hard limit, but if + # the model blew past it anyway, make one fresh single-turn call that + # quotes the too-long version and asks for a shorter rewrite. (The old + # SDK path did this as a true multi-turn; `claude -p` is one-shot, so we + # inline the prior output into the new prompt instead.) + if len(description) > 1024: + shorten_prompt = ( + f"{prompt}\n\n" + f"---\n\n" + f"A previous attempt produced this description, which at " + f"{len(description)} characters is over the 1024-character hard limit:\n\n" + f'"{description}"\n\n' + f"Rewrite it to be under 1024 characters while keeping the most " + f"important trigger words and intent coverage. Respond with only " + f"the new description in tags." + ) + shorten_text = _call_claude(shorten_prompt, model) + match = re.search(r"(.*?)", shorten_text, re.DOTALL) + shortened = match.group(1).strip().strip('"') if match else shorten_text.strip().strip('"') + + transcript["rewrite_prompt"] = shorten_prompt + transcript["rewrite_response"] = shorten_text + transcript["rewrite_description"] = shortened + transcript["rewrite_char_count"] = len(shortened) + description = shortened + + transcript["final_description"] = description + + if log_dir: + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / f"improve_iter_{iteration or 'unknown'}.json" + log_file.write_text(json.dumps(transcript, indent=2)) + + return description + + +def main(): + parser = argparse.ArgumentParser(description="Improve a skill description based on eval results") + parser.add_argument("--eval-results", required=True, help="Path to eval results JSON (from run_eval.py)") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--history", default=None, help="Path to history JSON (previous attempts)") + parser.add_argument("--model", required=True, help="Model for improvement") + parser.add_argument("--verbose", action="store_true", help="Print thinking to stderr") + args = parser.parse_args() + + skill_path = Path(args.skill_path) + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + eval_results = json.loads(Path(args.eval_results).read_text()) + history = [] + if args.history: + history = json.loads(Path(args.history).read_text()) + + name, _, content = parse_skill_md(skill_path) + current_description = eval_results["description"] + + if args.verbose: + print(f"Current: {current_description}", file=sys.stderr) + print(f"Score: {eval_results['summary']['passed']}/{eval_results['summary']['total']}", file=sys.stderr) + + new_description = improve_description( + skill_name=name, + skill_content=content, + current_description=current_description, + eval_results=eval_results, + history=history, + model=args.model, + ) + + if args.verbose: + print(f"Improved: {new_description}", file=sys.stderr) + + # Output as JSON with both the new description and updated history + output = { + "description": new_description, + "history": history + [{ + "description": current_description, + "passed": eval_results["summary"]["passed"], + "failed": eval_results["summary"]["failed"], + "total": eval_results["summary"]["total"], + "results": eval_results["results"], + }], + } + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/package_skill.py b/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 0000000..f48eac4 --- /dev/null +++ b/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import fnmatch +import sys +import zipfile +from pathlib import Path +from scripts.quick_validate import validate_skill + +# Patterns to exclude when packaging skills. +EXCLUDE_DIRS = {"__pycache__", "node_modules"} +EXCLUDE_GLOBS = {"*.pyc"} +EXCLUDE_FILES = {".DS_Store"} +# Directories excluded only at the skill root (not when nested deeper). +ROOT_EXCLUDE_DIRS = {"evals"} + + +def should_exclude(rel_path: Path) -> bool: + """Check if a path should be excluded from packaging.""" + parts = rel_path.parts + if any(part in EXCLUDE_DIRS for part in parts): + return True + # rel_path is relative to skill_path.parent, so parts[0] is the skill + # folder name and parts[1] (if present) is the first subdir. + if len(parts) > 1 and parts[1] in ROOT_EXCLUDE_DIRS: + return True + name = rel_path.name + if name in EXCLUDE_FILES: + return True + return any(fnmatch.fnmatch(name, pat) for pat in EXCLUDE_GLOBS) + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"❌ Error: Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"❌ Error: Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"❌ Error: SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("🔍 Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"❌ Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"✅ {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory, excluding build artifacts + for file_path in skill_path.rglob('*'): + if not file_path.is_file(): + continue + arcname = file_path.relative_to(skill_path.parent) + if should_exclude(arcname): + print(f" Skipped: {arcname}") + continue + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n✅ Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"❌ Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"📦 Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/quick_validate.py b/skills/skill-creator/scripts/quick_validate.py new file mode 100755 index 0000000..ed8e1dd --- /dev/null +++ b/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import sys +import os +import re +import yaml +from pathlib import Path + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + # Check SKILL.md exists + skill_md = skill_path / 'SKILL.md' + if not skill_md.exists(): + return False, "SKILL.md not found" + + # Read and validate frontmatter + content = skill_md.read_text() + if not content.startswith('---'): + return False, "No YAML frontmatter found" + + # Extract frontmatter + match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter_text = match.group(1) + + # Parse YAML frontmatter + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + + # Define allowed properties + ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata', 'compatibility'} + + # Check for unexpected properties (excluding nested keys under metadata) + unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES + if unexpected_keys: + return False, ( + f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. " + f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}" + ) + + # Check required fields + if 'name' not in frontmatter: + return False, "Missing 'name' in frontmatter" + if 'description' not in frontmatter: + return False, "Missing 'description' in frontmatter" + + # Extract name for validation + name = frontmatter.get('name', '') + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + # Check naming convention (kebab-case: lowercase with hyphens) + if not re.match(r'^[a-z0-9-]+$', name): + return False, f"Name '{name}' should be kebab-case (lowercase letters, digits, and hyphens only)" + if name.startswith('-') or name.endswith('-') or '--' in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + # Check name length (max 64 characters per spec) + if len(name) > 64: + return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters." + + # Extract and validate description + description = frontmatter.get('description', '') + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + # Check for angle brackets + if '<' in description or '>' in description: + return False, "Description cannot contain angle brackets (< or >)" + # Check description length (max 1024 characters per spec) + if len(description) > 1024: + return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters." + + # Validate compatibility field if present (optional) + compatibility = frontmatter.get('compatibility', '') + if compatibility: + if not isinstance(compatibility, str): + return False, f"Compatibility must be a string, got {type(compatibility).__name__}" + if len(compatibility) > 500: + return False, f"Compatibility is too long ({len(compatibility)} characters). Maximum is 500 characters." + + return True, "Skill is valid!" + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/skills/skill-creator/scripts/run_eval.py b/skills/skill-creator/scripts/run_eval.py new file mode 100755 index 0000000..e58c70b --- /dev/null +++ b/skills/skill-creator/scripts/run_eval.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Run trigger evaluation for a skill description. + +Tests whether a skill's description causes Claude to trigger (read the skill) +for a set of queries. Outputs results as JSON. +""" + +import argparse +import json +import os +import select +import subprocess +import sys +import time +import uuid +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +from scripts.utils import parse_skill_md + + +def find_project_root() -> Path: + """Find the project root by walking up from cwd looking for .claude/. + + Mimics how Claude Code discovers its project root, so the command file + we create ends up where claude -p will look for it. + """ + current = Path.cwd() + for parent in [current, *current.parents]: + if (parent / ".claude").is_dir(): + return parent + return current + + +def run_single_query( + query: str, + skill_name: str, + skill_description: str, + timeout: int, + project_root: str, + model: str | None = None, +) -> bool: + """Run a single query and return whether the skill was triggered. + + Creates a command file in .claude/commands/ so it appears in Claude's + available_skills list, then runs `claude -p` with the raw query. + Uses --include-partial-messages to detect triggering early from + stream events (content_block_start) rather than waiting for the + full assistant message, which only arrives after tool execution. + """ + unique_id = uuid.uuid4().hex[:8] + clean_name = f"{skill_name}-skill-{unique_id}" + project_commands_dir = Path(project_root) / ".claude" / "commands" + command_file = project_commands_dir / f"{clean_name}.md" + + try: + project_commands_dir.mkdir(parents=True, exist_ok=True) + # Use YAML block scalar to avoid breaking on quotes in description + indented_desc = "\n ".join(skill_description.split("\n")) + command_content = ( + f"---\n" + f"description: |\n" + f" {indented_desc}\n" + f"---\n\n" + f"# {skill_name}\n\n" + f"This skill handles: {skill_description}\n" + ) + command_file.write_text(command_content) + + cmd = [ + "claude", + "-p", query, + "--output-format", "stream-json", + "--verbose", + "--include-partial-messages", + ] + if model: + cmd.extend(["--model", model]) + + # Remove CLAUDECODE env var to allow nesting claude -p inside a + # Claude Code session. The guard is for interactive terminal conflicts; + # programmatic subprocess usage is safe. + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + cwd=project_root, + env=env, + ) + + triggered = False + start_time = time.time() + buffer = "" + # Track state for stream event detection + pending_tool_name = None + accumulated_json = "" + + try: + while time.time() - start_time < timeout: + if process.poll() is not None: + remaining = process.stdout.read() + if remaining: + buffer += remaining.decode("utf-8", errors="replace") + break + + ready, _, _ = select.select([process.stdout], [], [], 1.0) + if not ready: + continue + + chunk = os.read(process.stdout.fileno(), 8192) + if not chunk: + break + buffer += chunk.decode("utf-8", errors="replace") + + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + # Early detection via stream events + if event.get("type") == "stream_event": + se = event.get("event", {}) + se_type = se.get("type", "") + + if se_type == "content_block_start": + cb = se.get("content_block", {}) + if cb.get("type") == "tool_use": + tool_name = cb.get("name", "") + if tool_name in ("Skill", "Read"): + pending_tool_name = tool_name + accumulated_json = "" + else: + return False + + elif se_type == "content_block_delta" and pending_tool_name: + delta = se.get("delta", {}) + if delta.get("type") == "input_json_delta": + accumulated_json += delta.get("partial_json", "") + if clean_name in accumulated_json: + return True + + elif se_type in ("content_block_stop", "message_stop"): + if pending_tool_name: + return clean_name in accumulated_json + if se_type == "message_stop": + return False + + # Fallback: full assistant message + elif event.get("type") == "assistant": + message = event.get("message", {}) + for content_item in message.get("content", []): + if content_item.get("type") != "tool_use": + continue + tool_name = content_item.get("name", "") + tool_input = content_item.get("input", {}) + if tool_name == "Skill" and clean_name in tool_input.get("skill", ""): + triggered = True + elif tool_name == "Read" and clean_name in tool_input.get("file_path", ""): + triggered = True + return triggered + + elif event.get("type") == "result": + return triggered + finally: + # Clean up process on any exit path (return, exception, timeout) + if process.poll() is None: + process.kill() + process.wait() + + return triggered + finally: + if command_file.exists(): + command_file.unlink() + + +def run_eval( + eval_set: list[dict], + skill_name: str, + description: str, + num_workers: int, + timeout: int, + project_root: Path, + runs_per_query: int = 1, + trigger_threshold: float = 0.5, + model: str | None = None, +) -> dict: + """Run the full eval set and return results.""" + results = [] + + with ProcessPoolExecutor(max_workers=num_workers) as executor: + future_to_info = {} + for item in eval_set: + for run_idx in range(runs_per_query): + future = executor.submit( + run_single_query, + item["query"], + skill_name, + description, + timeout, + str(project_root), + model, + ) + future_to_info[future] = (item, run_idx) + + query_triggers: dict[str, list[bool]] = {} + query_items: dict[str, dict] = {} + for future in as_completed(future_to_info): + item, _ = future_to_info[future] + query = item["query"] + query_items[query] = item + if query not in query_triggers: + query_triggers[query] = [] + try: + query_triggers[query].append(future.result()) + except Exception as e: + print(f"Warning: query failed: {e}", file=sys.stderr) + query_triggers[query].append(False) + + for query, triggers in query_triggers.items(): + item = query_items[query] + trigger_rate = sum(triggers) / len(triggers) + should_trigger = item["should_trigger"] + if should_trigger: + did_pass = trigger_rate >= trigger_threshold + else: + did_pass = trigger_rate < trigger_threshold + results.append({ + "query": query, + "should_trigger": should_trigger, + "trigger_rate": trigger_rate, + "triggers": sum(triggers), + "runs": len(triggers), + "pass": did_pass, + }) + + passed = sum(1 for r in results if r["pass"]) + total = len(results) + + return { + "skill_name": skill_name, + "description": description, + "results": results, + "summary": { + "total": total, + "passed": passed, + "failed": total - passed, + }, + } + + +def main(): + parser = argparse.ArgumentParser(description="Run trigger evaluation for a skill description") + parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--description", default=None, help="Override description to test") + parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers") + parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds") + parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query") + parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold") + parser.add_argument("--model", default=None, help="Model to use for claude -p (default: user's configured model)") + parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") + args = parser.parse_args() + + eval_set = json.loads(Path(args.eval_set).read_text()) + skill_path = Path(args.skill_path) + + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + name, original_description, content = parse_skill_md(skill_path) + description = args.description or original_description + project_root = find_project_root() + + if args.verbose: + print(f"Evaluating: {description}", file=sys.stderr) + + output = run_eval( + eval_set=eval_set, + skill_name=name, + description=description, + num_workers=args.num_workers, + timeout=args.timeout, + project_root=project_root, + runs_per_query=args.runs_per_query, + trigger_threshold=args.trigger_threshold, + model=args.model, + ) + + if args.verbose: + summary = output["summary"] + print(f"Results: {summary['passed']}/{summary['total']} passed", file=sys.stderr) + for r in output["results"]: + status = "PASS" if r["pass"] else "FAIL" + rate_str = f"{r['triggers']}/{r['runs']}" + print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:70]}", file=sys.stderr) + + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/run_loop.py b/skills/skill-creator/scripts/run_loop.py new file mode 100755 index 0000000..30a263d --- /dev/null +++ b/skills/skill-creator/scripts/run_loop.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Run the eval + improve loop until all pass or max iterations reached. + +Combines run_eval.py and improve_description.py in a loop, tracking history +and returning the best description found. Supports train/test split to prevent +overfitting. +""" + +import argparse +import json +import random +import sys +import tempfile +import time +import webbrowser +from pathlib import Path + +from scripts.generate_report import generate_html +from scripts.improve_description import improve_description +from scripts.run_eval import find_project_root, run_eval +from scripts.utils import parse_skill_md + + +def split_eval_set(eval_set: list[dict], holdout: float, seed: int = 42) -> tuple[list[dict], list[dict]]: + """Split eval set into train and test sets, stratified by should_trigger.""" + random.seed(seed) + + # Separate by should_trigger + trigger = [e for e in eval_set if e["should_trigger"]] + no_trigger = [e for e in eval_set if not e["should_trigger"]] + + # Shuffle each group + random.shuffle(trigger) + random.shuffle(no_trigger) + + # Calculate split points + n_trigger_test = max(1, int(len(trigger) * holdout)) + n_no_trigger_test = max(1, int(len(no_trigger) * holdout)) + + # Split + test_set = trigger[:n_trigger_test] + no_trigger[:n_no_trigger_test] + train_set = trigger[n_trigger_test:] + no_trigger[n_no_trigger_test:] + + return train_set, test_set + + +def run_loop( + eval_set: list[dict], + skill_path: Path, + description_override: str | None, + num_workers: int, + timeout: int, + max_iterations: int, + runs_per_query: int, + trigger_threshold: float, + holdout: float, + model: str, + verbose: bool, + live_report_path: Path | None = None, + log_dir: Path | None = None, +) -> dict: + """Run the eval + improvement loop.""" + project_root = find_project_root() + name, original_description, content = parse_skill_md(skill_path) + current_description = description_override or original_description + + # Split into train/test if holdout > 0 + if holdout > 0: + train_set, test_set = split_eval_set(eval_set, holdout) + if verbose: + print(f"Split: {len(train_set)} train, {len(test_set)} test (holdout={holdout})", file=sys.stderr) + else: + train_set = eval_set + test_set = [] + + history = [] + exit_reason = "unknown" + + for iteration in range(1, max_iterations + 1): + if verbose: + print(f"\n{'='*60}", file=sys.stderr) + print(f"Iteration {iteration}/{max_iterations}", file=sys.stderr) + print(f"Description: {current_description}", file=sys.stderr) + print(f"{'='*60}", file=sys.stderr) + + # Evaluate train + test together in one batch for parallelism + all_queries = train_set + test_set + t0 = time.time() + all_results = run_eval( + eval_set=all_queries, + skill_name=name, + description=current_description, + num_workers=num_workers, + timeout=timeout, + project_root=project_root, + runs_per_query=runs_per_query, + trigger_threshold=trigger_threshold, + model=model, + ) + eval_elapsed = time.time() - t0 + + # Split results back into train/test by matching queries + train_queries_set = {q["query"] for q in train_set} + train_result_list = [r for r in all_results["results"] if r["query"] in train_queries_set] + test_result_list = [r for r in all_results["results"] if r["query"] not in train_queries_set] + + train_passed = sum(1 for r in train_result_list if r["pass"]) + train_total = len(train_result_list) + train_summary = {"passed": train_passed, "failed": train_total - train_passed, "total": train_total} + train_results = {"results": train_result_list, "summary": train_summary} + + if test_set: + test_passed = sum(1 for r in test_result_list if r["pass"]) + test_total = len(test_result_list) + test_summary = {"passed": test_passed, "failed": test_total - test_passed, "total": test_total} + test_results = {"results": test_result_list, "summary": test_summary} + else: + test_results = None + test_summary = None + + history.append({ + "iteration": iteration, + "description": current_description, + "train_passed": train_summary["passed"], + "train_failed": train_summary["failed"], + "train_total": train_summary["total"], + "train_results": train_results["results"], + "test_passed": test_summary["passed"] if test_summary else None, + "test_failed": test_summary["failed"] if test_summary else None, + "test_total": test_summary["total"] if test_summary else None, + "test_results": test_results["results"] if test_results else None, + # For backward compat with report generator + "passed": train_summary["passed"], + "failed": train_summary["failed"], + "total": train_summary["total"], + "results": train_results["results"], + }) + + # Write live report if path provided + if live_report_path: + partial_output = { + "original_description": original_description, + "best_description": current_description, + "best_score": "in progress", + "iterations_run": len(history), + "holdout": holdout, + "train_size": len(train_set), + "test_size": len(test_set), + "history": history, + } + live_report_path.write_text(generate_html(partial_output, auto_refresh=True, skill_name=name)) + + if verbose: + def print_eval_stats(label, results, elapsed): + pos = [r for r in results if r["should_trigger"]] + neg = [r for r in results if not r["should_trigger"]] + tp = sum(r["triggers"] for r in pos) + pos_runs = sum(r["runs"] for r in pos) + fn = pos_runs - tp + fp = sum(r["triggers"] for r in neg) + neg_runs = sum(r["runs"] for r in neg) + tn = neg_runs - fp + total = tp + tn + fp + fn + precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0 + accuracy = (tp + tn) / total if total > 0 else 0.0 + print(f"{label}: {tp+tn}/{total} correct, precision={precision:.0%} recall={recall:.0%} accuracy={accuracy:.0%} ({elapsed:.1f}s)", file=sys.stderr) + for r in results: + status = "PASS" if r["pass"] else "FAIL" + rate_str = f"{r['triggers']}/{r['runs']}" + print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:60]}", file=sys.stderr) + + print_eval_stats("Train", train_results["results"], eval_elapsed) + if test_summary: + print_eval_stats("Test ", test_results["results"], 0) + + if train_summary["failed"] == 0: + exit_reason = f"all_passed (iteration {iteration})" + if verbose: + print(f"\nAll train queries passed on iteration {iteration}!", file=sys.stderr) + break + + if iteration == max_iterations: + exit_reason = f"max_iterations ({max_iterations})" + if verbose: + print(f"\nMax iterations reached ({max_iterations}).", file=sys.stderr) + break + + # Improve the description based on train results + if verbose: + print(f"\nImproving description...", file=sys.stderr) + + t0 = time.time() + # Strip test scores from history so improvement model can't see them + blinded_history = [ + {k: v for k, v in h.items() if not k.startswith("test_")} + for h in history + ] + new_description = improve_description( + skill_name=name, + skill_content=content, + current_description=current_description, + eval_results=train_results, + history=blinded_history, + model=model, + log_dir=log_dir, + iteration=iteration, + ) + improve_elapsed = time.time() - t0 + + if verbose: + print(f"Proposed ({improve_elapsed:.1f}s): {new_description}", file=sys.stderr) + + current_description = new_description + + # Find the best iteration by TEST score (or train if no test set) + if test_set: + best = max(history, key=lambda h: h["test_passed"] or 0) + best_score = f"{best['test_passed']}/{best['test_total']}" + else: + best = max(history, key=lambda h: h["train_passed"]) + best_score = f"{best['train_passed']}/{best['train_total']}" + + if verbose: + print(f"\nExit reason: {exit_reason}", file=sys.stderr) + print(f"Best score: {best_score} (iteration {best['iteration']})", file=sys.stderr) + + return { + "exit_reason": exit_reason, + "original_description": original_description, + "best_description": best["description"], + "best_score": best_score, + "best_train_score": f"{best['train_passed']}/{best['train_total']}", + "best_test_score": f"{best['test_passed']}/{best['test_total']}" if test_set else None, + "final_description": current_description, + "iterations_run": len(history), + "holdout": holdout, + "train_size": len(train_set), + "test_size": len(test_set), + "history": history, + } + + +def main(): + parser = argparse.ArgumentParser(description="Run eval + improve loop") + parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--description", default=None, help="Override starting description") + parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers") + parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds") + parser.add_argument("--max-iterations", type=int, default=5, help="Max improvement iterations") + parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query") + parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold") + parser.add_argument("--holdout", type=float, default=0.4, help="Fraction of eval set to hold out for testing (0 to disable)") + parser.add_argument("--model", required=True, help="Model for improvement") + parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") + parser.add_argument("--report", default="auto", help="Generate HTML report at this path (default: 'auto' for temp file, 'none' to disable)") + parser.add_argument("--results-dir", default=None, help="Save all outputs (results.json, report.html, log.txt) to a timestamped subdirectory here") + args = parser.parse_args() + + eval_set = json.loads(Path(args.eval_set).read_text()) + skill_path = Path(args.skill_path) + + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + name, _, _ = parse_skill_md(skill_path) + + # Set up live report path + if args.report != "none": + if args.report == "auto": + timestamp = time.strftime("%Y%m%d_%H%M%S") + live_report_path = Path(tempfile.gettempdir()) / f"skill_description_report_{skill_path.name}_{timestamp}.html" + else: + live_report_path = Path(args.report) + # Open the report immediately so the user can watch + live_report_path.write_text("

Starting optimization loop...

") + webbrowser.open(str(live_report_path)) + else: + live_report_path = None + + # Determine output directory (create before run_loop so logs can be written) + if args.results_dir: + timestamp = time.strftime("%Y-%m-%d_%H%M%S") + results_dir = Path(args.results_dir) / timestamp + results_dir.mkdir(parents=True, exist_ok=True) + else: + results_dir = None + + log_dir = results_dir / "logs" if results_dir else None + + output = run_loop( + eval_set=eval_set, + skill_path=skill_path, + description_override=args.description, + num_workers=args.num_workers, + timeout=args.timeout, + max_iterations=args.max_iterations, + runs_per_query=args.runs_per_query, + trigger_threshold=args.trigger_threshold, + holdout=args.holdout, + model=args.model, + verbose=args.verbose, + live_report_path=live_report_path, + log_dir=log_dir, + ) + + # Save JSON output + json_output = json.dumps(output, indent=2) + print(json_output) + if results_dir: + (results_dir / "results.json").write_text(json_output) + + # Write final HTML report (without auto-refresh) + if live_report_path: + live_report_path.write_text(generate_html(output, auto_refresh=False, skill_name=name)) + print(f"\nReport: {live_report_path}", file=sys.stderr) + + if results_dir and live_report_path: + (results_dir / "report.html").write_text(generate_html(output, auto_refresh=False, skill_name=name)) + + if results_dir: + print(f"Results saved to: {results_dir}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/utils.py b/skills/skill-creator/scripts/utils.py new file mode 100755 index 0000000..51b6a07 --- /dev/null +++ b/skills/skill-creator/scripts/utils.py @@ -0,0 +1,47 @@ +"""Shared utilities for skill-creator scripts.""" + +from pathlib import Path + + + +def parse_skill_md(skill_path: Path) -> tuple[str, str, str]: + """Parse a SKILL.md file, returning (name, description, full_content).""" + content = (skill_path / "SKILL.md").read_text() + lines = content.split("\n") + + if lines[0].strip() != "---": + raise ValueError("SKILL.md missing frontmatter (no opening ---)") + + end_idx = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end_idx = i + break + + if end_idx is None: + raise ValueError("SKILL.md missing frontmatter (no closing ---)") + + name = "" + description = "" + frontmatter_lines = lines[1:end_idx] + i = 0 + while i < len(frontmatter_lines): + line = frontmatter_lines[i] + if line.startswith("name:"): + name = line[len("name:"):].strip().strip('"').strip("'") + elif line.startswith("description:"): + value = line[len("description:"):].strip() + # Handle YAML multiline indicators (>, |, >-, |-) + if value in (">", "|", ">-", "|-"): + continuation_lines: list[str] = [] + i += 1 + while i < len(frontmatter_lines) and (frontmatter_lines[i].startswith(" ") or frontmatter_lines[i].startswith("\t")): + continuation_lines.append(frontmatter_lines[i].strip()) + i += 1 + description = " ".join(continuation_lines) + continue + else: + description = value.strip('"').strip("'") + i += 1 + + return name, description, content diff --git a/skills/switchy-engine/.gitignore b/skills/switchy-engine/.gitignore new file mode 100644 index 0000000..694ceb4 --- /dev/null +++ b/skills/switchy-engine/.gitignore @@ -0,0 +1,6 @@ +# NEVER commit the Switchy token +.switchy_token +.switchy/ +*.token +# local reports may contain workspace data +*_report.csv diff --git a/skills/switchy-engine/SKILL.md b/skills/switchy-engine/SKILL.md new file mode 100644 index 0000000..92f1d44 --- /dev/null +++ b/skills/switchy-engine/SKILL.md @@ -0,0 +1,205 @@ +--- +name: switchy-engine +description: "Tracked-link + retargeting-pixel engine for Graeham Watts. The single source of truth for creating Switchy short links / QR codes that fire Meta/Google/etc. retargeting pixels on the redirect layer, and for pulling per-link scan/click analytics. Use this skill ANY time the user mentions: tracked link, short link, Switchy, shortlink, QR code, retargeting link, pixeled link, link analytics, scan count, click count, custom audience, retargeting audience, link in bio, UTM, swappable link, CTA link, or wants to know how a postcard/newsletter/listing/GBP link is performing. OTHER skills (content-creation-engine, newsletter-generator, weekly-listing-update, html-email, the postcard/Canva workflow, listing pages) CALL INTO this skill to mint tracked links instead of dropping raw destination URLs — build once, reference everywhere, so pixel + tracking logic never drifts. Also trigger on: 'wrap this link', 'make a tracked QR for the postcard', 'how many scans did X get', 'build the retargeting report', 'which links are feeding my audiences'." +--- + +# Switchy Engine — Tracked Links & Retargeting Pixels + +The **one** place link-shortening, QR generation, pixel-tagging, and click/scan +analytics live. Everything else (newsletter, postcards, listing pages, GBP, email +signature) should request a tracked link FROM this skill rather than pasting a raw +destination URL. That is the whole point: mint once, pixel consistently, measure +in one dashboard, never duplicate the logic. + +> **What Switchy does that a raw URL doesn't:** it sits on the redirect layer and +> fires retargeting pixels (Meta, Google/GA, LinkedIn, Pinterest, Bing, etc.) +> *before* the destination page loads, drops the visitor into a custom audience, +> tracks the scan/click, and lets you swap the destination later without changing +> the printed link or QR. Pixel fires even if the visitor bounces before the page +> renders. + +--- + +## Confirmed API facts (developers.switchy.io, verified May 2026) + +| Thing | Value | +|---|---| +| GraphQL endpoint | `https://graphql.switchy.io/v1/graphql` (POST, **queries only**) | +| REST link-create | `POST https://api.switchy.io/v1/links/create` (mutations are REST, not GraphQL) | +| Auth header | `Api-Authorization: ` — **not** `Authorization: Bearer` | +| Token scope | One token per **workspace**; API-key style, **not** OAuth | +| Schema style | Hasura (`where: { field: { _is_null: true } }` filter syntax) | +| Pixel platforms supported | linkedin, facebook, gtm, quora, pinterest, twitter, ga, bing, nexus, adroll, adwords | +| Rate limits (create) | 10,000 links/day, 1,000 links/hour | + +### ⚠️ Two things that are NOT settled and must be confirmed live +1. **Per-link click/scan field name.** Public docs only show workspace-level + fields (`workspaces`, `domains`). The field that holds per-link click/scan + counts is **not documented** — you MUST introspect it on the live token before + trusting any analytics query. Run `scripts/switchy_analytics.py --confirm-schema`. +2. **Token activation.** Switchy restricts API access; an account may need to ask + Switchy **live chat** to enable API access before the generated token returns + data. Confirm the token actually returns rows before wiring downstream skills. + +### ⚠️ Platform caveat: no native TikTok pixel +Switchy's pixel platform list has no TikTok entry. For TikTok-sourced traffic, +either route through `gtm` (Google Tag Manager container that carries the TikTok +pixel) or accept click-tracking only. Don't promise native TikTok retargeting. + +--- + +## Token setup (do this once) + +1. Log in to Switchy → open the target **workspace** → **Settings → Integrations + → Generate a token**. +2. If queries return empty/!errors, message Switchy **live chat** to enable API + access for the account, then regenerate. +3. Store it — never hardcode, never commit: + - Windows: `setx SWITCHY_API_TOKEN "your-token"` + - mac/linux: `export SWITCHY_API_TOKEN="your-token"` (or `~/.switchy/token`, chmod 600) +4. Lock the schema: `python scripts/switchy_analytics.py --confirm-schema` + → note the real click/scan field, pass it as `--click-field`. + +--- + +## What this skill exposes to other skills (the contract) + +Downstream skills should call ONE of these instead of emitting a raw URL: + +- **`mint(destination, tags[], pixels[], domain?, slug?)`** → returns a Switchy + short URL + (optional) QR PNG. Implemented via the REST create endpoint + (`api.switchy.io/v1/links/create`). Always pass `tags` so the analytics layer + can segment by source (e.g. `["newsletter","consumer"]`, `["postcard","qr","94303"]`). +- **`report()`** → runs `scripts/switchy_analytics.py`, returns the + scans→audience→budget table (markdown + CSV). + +> **Tagging convention (mandatory).** Every minted link gets: +> `surface` (gbp / newsletter / postcard / listing / signature / openhouse / yardsign / social-bio …), +> `audience-class` (`consumer` | `prospect` | `b2b` | `mixed`), +> and an optional `campaign` tag. The `audience-class` tag is what lets us EXCLUDE +> junk (vendor/agent clicks) from retargeting audiences. See +> `references/audience-hygiene.md`. + +--- + +## Analytics: the core query flow + +`scripts/switchy_analytics.py` does three things: + +1. **`--confirm-schema`** — introspects the `links` type and prints real field + names (mandatory first run on a live token). +2. **fetch** — queries all live links + their click/scan counts. +3. **model** — converts each link's clicks into a *targetable retargeting + audience* and the *monthly ad budget that audience justifies* (frequency × CPM), + flagging audiences too small to target. + +```bash +python scripts/switchy_analytics.py --confirm-schema # step 1, once +python scripts/switchy_analytics.py --click-field clicks # normal run +python scripts/switchy_analytics.py --cpm 25 --frequency 12 # tune the model +``` + +Runs in **DEMO mode** with illustrative numbers if no token is set, so the output +format can be reviewed before go-live. + +See `references/graphql-queries.md` for the raw queries (introspection, per-link +analytics, both scalar-count and aggregate shapes). + +--- + +## When to use vs. not + +**Use** when minting any link/QR that will sit in front of consumer or prospect +traffic, or when reporting on link/QR/scan performance. + +**Don't bother wrapping** (raw URL is fine) when: +- The destination is Graeham's OWN already-pixeled site AND you don't need + per-source attribution, destination-swapping, or multi-pixel firing (see + `references/retargeting-pathway-map.md` → "own-site redundancy"). +- The surface is a GBP **primary website field** — Google auto-removes redirect / + shortener links there. Use the real domain in that field; use Switchy in GBP + *posts* and secondary links instead. See `references/gbp-and-youtube.md`. + +--- + +## Reference docs +- `references/graphql-queries.md` — introspection + analytics queries, REST create payload. +- `references/retargeting-pathway-map.md` — every surface, traffic type, retargeting value, caveats. +- `references/gbp-and-youtube.md` — the GBP redirect-policy answer + YouTube/own-site pixel-redundancy answer. +- `references/audience-hygiene.md` — what to pixel vs. skip, and source segmentation. +- `references/architecture-decision.md` — why this is a standalone called-into engine. +--- + +## Live-verified API capabilities (2026-05-28, real token) + +Confirmed by introspecting the live GraphQL schema. **Read this before promising a metric.** + +**Queryable top-level types:** `links`, `folders`, `pixels`, `domains`, `UTMTemplates`, `tokens`, `workspaces` (+ `_by_pk`). + +**Per-link data you CAN get:** `id` (slug), `domain`, `url` (destination), `title`, +`tags`, `pixels`, `folderId`, `createdDate`, and **`clicks`** (total click/scan count — a QR scan and a link click both increment this). + +**What you CANNOT get from the API (important):** +- **No per-click detail** — no referrer, no country/geo, no device, no timestamp-per-click. +- **No time-series** — only a running total. `uniq` is an internal ID, NOT a unique-visitor count. +- There is no clicks/stats/events table in the schema. + +**Implications:** +1. **"Where clicks come from" must be ENGINEERED, not queried.** Source attribution lives in + how each link is built — its **slug + tags + UTM**. A bare/untagged link is unattributable. + This is why tagging discipline (below) is mandatory. (Richer geo/referrer/device stats DO + exist in the Switchy *dashboard UI* per link, but are not exposed to the API.) +2. **Weekly trends require our own snapshots.** Since the API only returns a running total, + the weekly digest must SNAPSHOT every link's `clicks` each Monday and DIFF against last + week's snapshot to report "scans this week." Snapshots stored as dated JSON/CSV. +3. GA4 (via the UTM) and Meta (via the pixel) hold the richer behavioral/audience data — + cross-reference there for on-site behavior and audience size. + +## Naming & folder convention (mandatory — keeps everything decodable) +- **Slug:** `--` e.g. `epa-comps-0601` +- **Title:** `Postcard EPA 2026-06-01 — Last 5 Homes (home value)` (date YYYY-MM-DD so it sorts) +- **Tags:** `surface` + `audience-class` + market + date, e.g. `["postcard","qr","consumer","epa","2026-06-01"]` +- **UTM:** `utm_source=&utm_medium=&utm_campaign=_&utm_content=` +- **Switchy folder:** by surface — `Post card qr` (id 92811), `Yard Sign QR` (80707), GMB folders, etc. + +## Weekly digest (Monday) — design +A **scheduled task** (not duplicated in postcard/content skills) calls this engine every +Monday: it snapshots all link `clicks`, diffs against last week, and produces a dashboard + +email showing scans-this-week per source, audience growth, and suggested budget. Published to +`Graehamwatts/online-content/dashboards/switchy/` (hosted) and emailed. Because trend data +depends on our snapshots, the FIRST run only establishes a baseline (deltas start week 2). + +## Callable contract (how other skills use this) +- `farming-postcard` / `content-creation-engine` call `mint()` here instead of emitting a raw + URL — they pass destination + tags + pixels, get back a tracked short link + QR. +- Any skill can call `report()` to pull the current scans→audience→budget table. +- Constants (pixel IDs, default domain, tag vocab) live in `shared-references/switchy.json`. + +--- + +## QR generation workflow (Cowork → Chrome → Switchy → dashboard) + +**Trigger:** user says "generate a QR code for this postcard" and uploads the postcard. + +1. **Destination + UTM.** Resolve the landing page from `farming-postcard/references/cta-router.md` + (home-value default: `https://graehamwatts.com/evaluation`). Append: + `?utm_source=postcard&utm_medium=direct_mail&utm_campaign=epa_&utm_content=`. +2. **Create the tracked link via REST** (`POST https://api.switchy.io/v1/links/create`, + header `Api-Authorization:`): set a clean `id` slug, `title` + (`Postcard `), `folderId` (Post card qr = **92811**), + `tags` (`postcard,qr,consumer,,`), and `pixels` from + `shared-references/switchy.json` (Meta 963211690980393, GA4 G-S82GF32XJT, Ads AW-1047225119). +3. **Get the QR via Claude-in-Chrome.** Open switchy.io → **the user logs in themselves** + (Claude must NOT enter passwords or solve the reCAPTCHA). Then: link list → find the link → + click **"Download QR Code"** → QR designer → **Download as PNG** (lands in Downloads). + Save/rename as `Postcard__.png`. +4. **Hand the QR to the user** to embed in the Canva postcard. The destination is swappable + later in Switchy without changing the printed QR. +5. **Publish to the dashboard + sync to GitHub.** Run `scripts/switchy_dashboard.py`, then push + `online-content/dashboards/switchy/index.html` (+ the dated snapshot) to `Graehamwatts/skills` + via the **GitHub Contents API** (`PUT .../contents/` with the github token) — NOT local + git, because the working tree's `.git/index.lock` blocks commits on this machine. The live + dashboard at `graehamwatts.github.io/skills/online-content/dashboards/switchy/` then updates. + +> Pages serves from main root, so any file pushed under `online-content/` is live at +> `graehamwatts.github.io/skills/online-content/...`. diff --git a/skills/switchy-engine/references/architecture-decision.md b/skills/switchy-engine/references/architecture-decision.md new file mode 100644 index 0000000..e95c244 --- /dev/null +++ b/skills/switchy-engine/references/architecture-decision.md @@ -0,0 +1,72 @@ +# Architecture Decision — standalone `switchy-engine` + +## Recommendation (validated, with one refinement) +**Build `switchy-engine` as a standalone skill that other skills call into — +YES.** Add one refinement: the durable *constants* (pixel IDs, default domain, +tag vocabulary, vendor-exclusion list location) live in `shared-references`, so +the engine and every caller read one source of truth. Capability = skill; +constants = shared config. + +## Why the hypothesis holds (receipts from the actual skill stack) +The codebase already proves both halves of this pattern: + +- **"Build once, reference everywhere" is the established norm.** `cma-generator` + is called by `newsletter-generator`'s home-value CTA; `content-calendar` hands + topics to `content-creation-engine`; multiple skills read + `../shared-references/identity.json` instead of hardcoding brand facts; + `github-skill-sync` is a horizontal utility that other skills invoke after any + change. A cross-cutting engine that many skills call is the system's own idiom. + +- **Burying a cross-cutting capability inside a host skill has already failed + here — twice.** `content-creation-engine`'s own changelog records that + `video-research-engine` went *dormant* inside it ("most users didn't know it + existed, trigger keywords didn't match how people speak, visual analysis was + coupled to content generation when it should be standalone") and was extracted + into `video-watcher` + `video-transcriber`. Link/pixel logic is exactly that + kind of horizontal capability. Embedding it in newsletter or content engine + would repeat the documented mistake. + +## Alternatives considered (and why they lose) + +| Option | Verdict | Reason | +|---|---|---| +| **A. Duplicate link/pixel logic in each skill** | ❌ Reject | Guaranteed drift; token-handling code copied into 6+ places = 6 ways to leak a credential; pixel list + tag vocab diverge. This is the anti-pattern the repo fought by merging. | +| **B. Config-only in `shared-references`, no skill** | ⚠️ Partial | Constants belong there, but Switchy needs live API calls, its own analytics output, and its own trigger surface ("how many scans did X get?"). That's an invokable capability, not passive data. | +| **C. Fold into an existing skill** (content-calendar / ghl-crm-audit) | ❌ Reject | Switchy spans far past any one host — postcards, yard signs, GBP, listings, email. Coupling to one host = the dormancy trap again. | +| **D. Build a Switchy MCP** | ❌ Overkill | No MCP exists; brief says call the API directly. An MCP is heavy for a single-user read API. A skill is right-sized. | +| **E. Standalone skill + shared constants** | ✅ **Adopt** | Capability gets its own trigger + analytics; constants stay single-sourced; callers reference by name. | + +## The call contract (what callers use) +Downstream skills stop emitting raw consumer URLs and instead: +- **mint(destination, tags[], pixels[], domain?, slug?)** → Switchy short URL (+QR). +- **report()** → scans→audience→budget table. +Constants (`pixels[]` defaults, default `domain`, tag vocabulary, exclusion-list +path) come from `shared-references/switchy.json` (to be created — see asks). + +## Per-skill wiring points (from the STEP 1 inspection) + +| Skill | Link/QR emission point today | Action | +|---|---|---| +| **newsletter-generator** (EPA Report) | "Watch the full video" → YouTube; "What's My Home Worth?" → graehamwatts.com/home-value; footer social | **Wrap all CTAs.** Highest-value surface (opted-in consumers). Add: "mint each CTA via switchy-engine, tags `newsletter`+`consumer`." | +| **content-creation-engine** | YouTube CTAs, social post links, link-in-bio across 14 formats | **Wrap consumer CTAs + bio links.** Add mint step in the content-package output; tag by platform + `consumer`. | +| **html-email** | CTA buttons in designed emails | **Wrap CTAs conditionally.** Many of these go to partners/coaches (B2B) — tag `b2b`, track-only, don't pixel. Consumer emails → wrap + pixel. | +| **weekly-listing-update** | Seller-facing report; "view listing online" type links | **Track-only.** Audience is a single known seller (sphere) — don't pixel. Optional Switchy for click visibility. | +| **listing-remarks-writer** | NONE — MLS public remarks legally cannot contain URLs/contact info | **No wrap in remarks.** Flag: the *marketing collateral* around the listing (single-property page, flyers, QR) is where tracked links go, not the MLS remarks themselves. | +| **postcard workflow (Canva — no skill)** | QR codes designed manually in Canva | **GAP.** See below. | + +## Postcard integration (CORRECTED 2026-05-28) +A `farming-postcard` skill DOES exist (earlier inspection used the session's +plugin-mounted skills copy, which omitted it; the source-of-truth repo at +Documents/Claude/Skills has it). It already renders print-ready cards in the locked +brand and routes each card's QR target through `references/cta-router.md`. That +router is the clean integration point: instead of a raw landing URL it returns a +**Switchy short link** (pixel + UTM baked in), and the QR encodes that. One change, +every future postcard becomes a pixeled, scan-tracked, swappable-destination +retargeting surface. No new skill needed — farming-postcard CALLS INTO +switchy-engine, which strengthens the standalone-engine decision. + +## Net +Standalone `switchy-engine` + `shared-references/switchy.json` constants, callers +reference by name, postcard QR routed through the engine. This matches the stack's +proven idioms and avoids its two documented failure modes (duplication-drift and +buried-capability dormancy). diff --git a/skills/switchy-engine/references/audience-hygiene.md b/skills/switchy-engine/references/audience-hygiene.md new file mode 100644 index 0000000..94fe661 --- /dev/null +++ b/skills/switchy-engine/references/audience-hygiene.md @@ -0,0 +1,63 @@ +# Audience Hygiene — what to pixel, what to skip, how to segment + +**The trap:** pixeling indiscriminately. A retargeting audience is only as good as +who's in it. Two ways to wreck it: + +1. **Tiny audiences.** Below ~100 you can't target at all (Meta floor). Below + ~1,000 the algorithm has too little to optimize and you overpay. A postcard + slug with 95 scans isn't an audience yet — it's noise. +2. **Wrong people.** Other agents, vendors, lenders, title reps, and your own team + clicking an email signature or LinkedIn link get pixeled as if they were + buyers/sellers. You then spend ad dollars showing listing ads to your title rep. + This drags CTR down, raises CPMs, and corrupts lookalike seeds built from the + audience. + +## Pixel vs. skip, by surface + +**PIXEL (consumer / prospect-facing):** +- EPA Report newsletter, GHL SMS, single-property & listing pages +- GBP posts + secondary links, Zillow/Realtor.com profiles, Nextdoor +- Instagram/Facebook bios, YouTube description/pinned/channel links +- All offline QR → postcards, yard riders, open-house flyers & sign-in, mailers, + event banners, window cards + +**SKIP the pixel (track clicks only, or don't wrap):** +- **Email signature** — every email to a vendor/agent/escrow gets them pixeled. + Track-only or omit. If wrapped, hard-tag `b2b`/`mixed` and exclude. +- **LinkedIn** — predominantly B2B/peer traffic. Track-only or exclude from + consumer audiences. +- **Business cards handed to peers**, networking events aimed at the industry. +- **Sphere / past-client touches (PCFS)** — known people; retargeting them wastes + spend. Measure engagement, don't build ad audiences from them. + +## How to segment so junk can be excluded + +The mechanism is **tags on every minted link** (the engine enforces this): + +- `audience-class`: `consumer` | `prospect` | `b2b` | `mixed` +- `surface`: gbp / newsletter / postcard / listing / signature / openhouse / … +- `campaign`: optional (e.g. `94303-spring-farm`) + +Then in the ad platform build audiences from the **clean** classes only: + +1. **Separate Switchy pixels or events by class where possible.** Simplest robust + pattern: use a distinct destination-path or event per `audience-class` so the + Meta/GA audience rule can include `consumer` traffic and exclude `b2b`. (Switchy + fires the pixel on redirect; the cleanest split is one pixel + a class-specific + URL parameter, or separate links per class.) +2. **Build the retargeting audience = `consumer` + `prospect` sources only.** + Never include `b2b`/`mixed`. +3. **Maintain a standing EXCLUSION audience** of known agents/vendors/team (upload + their emails as a Meta custom audience) and exclude it from every campaign. This + catches B2B people even when they slip through a consumer surface. +4. **Min-size gate before spending.** The analytics script flags any audience under + 100 (untargetable) and 100–999 (fold into a combined audience). Don't run a + campaign against a sub-1,000 standalone audience — merge by surface first + (e.g. all `listing` slugs → one "listing-viewers" audience). +5. **Lookalike seeds from clean audiences only.** A lookalike built off a polluted + seed inherits the pollution at scale — the most expensive version of the mistake. + +## One-line policy for the engine +> Pixel consumer/prospect surfaces; track-only the B2B/known ones; tag every link +> with `audience-class`; build ad audiences from `consumer`+`prospect` minus a +> standing vendor/agent exclusion list; never spend against a sub-1,000 standalone. diff --git a/skills/switchy-engine/references/gbp-and-youtube.md b/skills/switchy-engine/references/gbp-and-youtube.md new file mode 100644 index 0000000..3ec34ed --- /dev/null +++ b/skills/switchy-engine/references/gbp-and-youtube.md @@ -0,0 +1,71 @@ +# GBP redirect policy + YouTube/own-site pixel redundancy + +## Part A — Google Business Profile: can a Switchy link go there? + +**Short answer: NOT in the primary website field. Yes in posts and secondary +links, with care.** + +Google's Business links policy explicitly prohibits URLs that "redirect or +'refer' users to landing pages... other than those of the actual business," and +Google now runs automated link verification that removes violating links. Link +shorteners in the **primary website field** are a documented enforcement target — +there are real cases of booking/shortened links getting pulled (e.g. a contractor +losing a large share of Google-sourced leads when a redirect link was removed). +Google tolerates *short, clean* UTM strings but flags long/promotional ones. + +**Verdict by GBP surface:** + +| GBP surface | Switchy redirect OK? | What to do | +|---|---|---| +| **Primary website field** | ❌ High risk of auto-removal | Put your real domain (`graehamwatts.com`). Pixel it natively with the Meta/GA tags already on the site. Don't gamble your map-pack click here. | +| **Appointment / menu / "Links" fields** | ⚠️ Lower risk | A clean branded short link is usually fine; monitor for removal. Prefer your own domain with a tracked path if nervous. | +| **GBP Posts (update/offer/event)** | ✅ Safe | This is the right home for Switchy on GBP. Each post link is a fresh pixel hook. | + +### GBP-driven use cases (every one pixels the clicker, then retargets) +- **GBP post link → YouTube channel/video:** every GBP clicker who lands on your + pixeled redirect gets dropped into a custom audience *and* sent to a video. + You retarget high-intent local searchers who watched your content. HIGH value — + this is the headline play. +- **GBP post link → single-property page:** local searcher → listing → pixel → + retarget with more listings / "what's my home worth." +- **GBP post link → EPA Report signup / home-value form:** capture + pixel. +- **GBP "appointment" link → GHL booking:** pixel before the booking page. +- **GBP product/services link → CMA landing page:** pixel seller-intent traffic. + +> Net: GBP is one of Graeham's highest-intent traffic sources, but the pixel has +> to be captured through **posts and secondary links**, never the website field. +> The field stays clean; the posts do the retargeting work. + +## Part B — YouTube & own-site links: is the pixel redundant? + +**Yes — when a Switchy link points to Graeham's OWN already-pixeled site, the +pixel-drop is largely redundant**, because the site's own Meta/GA tags will pixel +that visitor the instant the page loads anyway. The Switchy pixel and the on-site +pixel capture nearly the same person. + +But "redundant pixel" ≠ "useless link." Switchy still earns its place for four +non-pixel reasons: + +1. **Per-source attribution.** A unique slug per surface (YT description vs. pinned + comment vs. channel link vs. GBP) tells you *which* surface drove the visit — + something a bare `graehamwatts.com` link buried among many can't. +2. **Swappable destination.** Change where a printed/published link points without + editing the video, postcard, or sign. Critical for QR codes you can't reprint. +3. **Multi-pixel firing.** Fire Meta + Google + LinkedIn + Pinterest from one link + even if the destination page only carries one or two of those tags. +4. **Pixel-fires-before-page-load.** The redirect pixels the visitor even if they + bounce before the destination renders (slow connection, immediate back-tap) — + capturing people the on-site pixel would miss. + +### Per-surface recommendation + +| Destination type | Is Switchy essential? | Why | +|---|---|---| +| **Non-owned platform** (YouTube watch page, Zillow, Nextdoor, a partner site) | **ESSENTIAL** | You can't put your pixel on someone else's page. The redirect is your only capture point. | +| **Your own pixeled site, and you want attribution / swap / multi-pixel** | **WORTH IT** | Pixel is redundant but the other three benefits stand. | +| **Your own pixeled site, single known placement, no swap needed** | **OPTIONAL** | Raw URL pixels them fine on load. Use Switchy only if you want the click count. | +| **Your own site, but link is on a QR / print you can't easily change** | **WORTH IT** | Swappable destination alone justifies it. | + +**Rule of thumb for the engine:** if the destination is NOT a Graeham-owned +pixeled page → always wrap. If it IS → wrap only when you need attribution, +swappability, multi-pixel, or pre-load capture; otherwise the raw URL is fine. diff --git a/skills/switchy-engine/references/graphql-queries.md b/skills/switchy-engine/references/graphql-queries.md new file mode 100644 index 0000000..5c565f7 --- /dev/null +++ b/skills/switchy-engine/references/graphql-queries.md @@ -0,0 +1,110 @@ +# Switchy GraphQL & REST — raw queries + +All GraphQL calls: `POST https://graphql.switchy.io/v1/graphql`, header +`Api-Authorization: `. Queries only — there are no GraphQL mutations. + +## 0. Smoke test (confirms token is active) +Matches the documented example. If this returns your workspace, the token works. +```graphql +query SmokeTest { + workspaces { id name companyName createdDate } + domains(where: { removedDate: { _is_null: true } }) { name createdDate } +} +``` +If it returns `errors` or empty, the token likely needs API access enabled via +Switchy live chat. + +## 1. Confirm the per-link field names (MANDATORY before analytics) +The public docs never document per-link analytics fields. Introspect them: +```graphql +query ConfirmLinksType { + __type(name: "links") { + name + fields { name description type { name kind ofType { name kind } } } + } +} +``` +Scan the output for the click/scan counter. Likely candidates given the Hasura +schema: a scalar like `clicks` / `clicksCount` / `visits`, OR a relationship +exposed as `clicks_aggregate { aggregate { count } }`. **Do not assume — confirm.** +If `links` isn't the type name, run full introspection: +```graphql +query { __schema { queryType { name } types { name kind } } } +``` + +## 2. Per-link analytics — scalar-count shape (try first) +Replace `clicks` with whatever step 1 revealed. +```graphql +query LinkAnalytics { + links(where: { removedDate: { _is_null: true } }) { + id # this is the slug (domain/id is the short URL) + domain + url # destination + title + tags + clicks # <-- VERIFY this field name via introspection + } +} +``` + +## 3. Per-link analytics — aggregate shape (fallback) +If clicks live in a child table (Hasura exposes `_aggregate`): +```graphql +query LinkAnalyticsAggregate { + links(where: { removedDate: { _is_null: true } }) { + id domain url title tags + clicks_aggregate { aggregate { count } } + } +} +``` +`scripts/switchy_analytics.py` tries shape #2 then falls back to #3 automatically. + +## 4. Time-windowed clicks (if a clicks/events table exists) +Once introspection reveals the events table + its timestamp column, filter by date +for week-over-week reporting (column names are placeholders — confirm them): +```graphql +query ClicksLast30d($since: timestamptz!) { + links(where: { removedDate: { _is_null: true } }) { + id domain + clicks_aggregate(where: { createdDate: { _gte: $since } }) { + aggregate { count } + } + } +} +``` + +## 5. Creating a tracked, pixeled link (REST — not GraphQL) +```bash +curl 'https://api.switchy.io/v1/links/create' \ + -H 'Content-Type: application/json' \ + -H 'Api-Authorization: YOUR_TOKEN' \ + -d '{ + "link": { + "url": "https://graehamwatts.com/home-value", + "domain": "hi.switchy.io", + "id": "epa-report", + "title": "EPA Report CTA", + "tags": ["newsletter","consumer"], + "pixels": [ + { "platform": "facebook", "value": "FB_PIXEL_ID" }, + { "platform": "ga", "value": "G-XXXXXXX" } + ], + "showGDPR": true + }, + "autofill": true + }' +``` +- `pixels[].platform` ∈ {linkedin, facebook, gtm, quora, pinterest, twitter, ga, + bing, nexus, adroll, adwords}. **No tiktok** — route TikTok via `gtm`. +- `showGDPR: true` shows a consent popup when pixels are present. In CA, leaving it + true is the safer default for cold consumer traffic; it slightly reduces match + rate. Decision flagged for Graeham. +- Premium domains `hi.switchy.io` / `swiy.io` are only available to *official* + integrations via API; your default workspace domain is used otherwise. +- QR codes: Switchy generates a QR for any link in-app. Via API, generate the link + then render the QR client-side (any QR lib encoding the short URL), or export + from the Switchy dashboard. A QR scan = a click = a pixel fire on redirect. + +## Notes +- `id` in the link object IS the slug; the public short URL is `domain/id`. +- Always pass `tags` — the analytics + audience-hygiene layer segments on them. diff --git a/skills/switchy-engine/references/retargeting-pathway-map.md b/skills/switchy-engine/references/retargeting-pathway-map.md new file mode 100644 index 0000000..4c16074 --- /dev/null +++ b/skills/switchy-engine/references/retargeting-pathway-map.md @@ -0,0 +1,51 @@ +# Retargeting Pathway Map — every surface a Switchy link/QR can live + +Legend — **Retargeting value**: HIGH = lots of net-new pixelable consumer traffic +you can't capture otherwise; MED = useful but smaller/partly redundant; LOW = +tiny, redundant, or audience-polluting. **Pixel?**: should this surface actually +drop people into a retargeting audience, or just track clicks? + +| # | Surface | Traffic type | Retargeting value | Pixel? | Platform caveat | +|---|---|---|---|---|---| +| 1 | **GBP — primary website field** | High-intent local searchers | HIGH (the traffic) / N/A (can't pixel here) | ❌ raw domain only | Google auto-removes redirect/shortener links from the website field. Put the real site here; pixel it natively. | +| 2 | **GBP — "Links" / appointment / menu links** | High-intent local | HIGH | ✅ | Secondary links tolerate more; still keep them clean. Use Switchy here, not field #1. | +| 3 | **GBP — Posts (update/offer/event)** | High-intent local | HIGH | ✅ | Posts are the safest GBP home for a Switchy link. Each post link → pixel. | +| 4 | **Instagram bio link / link-in-bio** | Warm social, consumer | HIGH | ✅ | IG in-app browser sometimes limits 3rd-party cookies → lower match; pixel still fires server-friendly events. | +| 5 | **Facebook page bio / about link** | Warm social, consumer | HIGH | ✅ | FB pixel matches best here (same ecosystem). | +| 6 | **LinkedIn bio / featured link** | Mixed (agents, vendors, some clients) | LOW–MED | ⚠️ selective | Heavy B2B/agent traffic — tag `b2b`, EXCLUDE from consumer audiences. LinkedIn pixel available. | +| 7 | **TikTok bio link** | Cold-warm consumer | MED | ⚠️ click-only or GTM | **No native TikTok pixel in Switchy** — route via `gtm` or accept click tracking only. | +| 8 | **YouTube — video descriptions** | Warm consumer (already engaged) | MED–HIGH | ✅ (if non-owned dest) | If link points to your OWN pixeled site, pixel benefit is largely redundant (see gbp-and-youtube.md). Value = attribution + swappable + multi-pixel. | +| 9 | **YouTube — pinned comment** | Warm consumer | MED | ✅ | Same redundancy logic as #8. High CTR placement. | +| 10 | **YouTube — channel "Links" section** | Warm consumer | MED | ✅ | Persistent; good for a single evergreen tracked link. | +| 11 | **Email signature** | MIXED — clients, agents, vendors, title, lenders | LOW | ❌ skip pixel | Pollutes audiences with B2B. Track clicks only, or omit. Tag `b2b`/`mixed`, exclude. | +| 12 | **The EPA Report newsletter** | Warm consumer subscribers | HIGH | ✅ | Already opted-in; cleanest audience you have. Per-section links → per-topic audiences. | +| 13 | **GHL SMS / text campaigns** | Warm leads/prospects | HIGH | ✅ | SMS clicks open in-app browsers; match rate varies but intent is high. Keep slugs short for SMS. | +| 14 | **Single-property / listing pages** | High-intent buyers + neighbor-snoops | HIGH | ✅ | Goldmine — buyers AND likely future sellers (neighbors). Tag per listing. | +| 15 | **Zillow profile link** | High-intent consumer | HIGH | ✅ | Zillow may wrap/normalize outbound links; test the redirect survives. Non-owned-platform traffic you otherwise can't pixel. | +| 16 | **Realtor.com profile link** | High-intent consumer | HIGH | ✅ | Same as Zillow — confirm link isn't stripped. | +| 17 | **Postcards (QR)** — *currently Canva, no skill* | Cold-warm farm/geo consumer | MED–HIGH | ✅ | QR scan = pixel fire. Per-ZIP/per-drop slug = measurable direct mail + a retargeting audience from offline mail. Big unlock. | +| 18 | **Yard sign riders (QR)** | Cold-warm local drive-by | MED | ✅ | Scanners are physically in the neighborhood = prime seller/buyer geo audience. Per-sign slug. | +| 19 | **Open house flyers (QR)** | Warm in-person buyers | HIGH | ✅ | Self-selected high intent. Pair with #20. | +| 20 | **Open house sign-in (QR → form)** | Warm in-person | HIGH | ✅ | Pixel + lead capture in one scan. Tag `openhouse`. | +| 21 | **Business cards (QR)** | MIXED | LOW–MED | ⚠️ selective | Handed to clients AND peers. Two cards or two QRs: consumer vs. networking. Tag accordingly. | +| 22 | **Event banners / sponsorships (QR)** | Cold-warm local consumer | MED | ✅ | Per-event slug measures sponsorship ROI + builds a local audience. | + +## Additional surfaces worth adding +| # | Surface | Traffic type | Value | Pixel? | Caveat | +|---|---|---|---|---|---| +| 23 | **Just-listed / just-sold mailers (QR)** | Cold farm consumer | MED–HIGH | ✅ | Same engine as postcards; per-campaign slug. | +| 24 | **Property video end-screens / pinned (HeyGen/Reels)** | Warm consumer | MED | ✅ | One tracked link reused across a video's surfaces. | +| 25 | **Nextdoor / community group posts** | Warm hyper-local | MED–HIGH | ✅ | Genuinely local; can't pixel Nextdoor natively, so Switchy is the only capture. | +| 26 | **Google Business / Apple Maps "appointment" links** | High-intent | MED | ✅ | Apple Maps tolerates redirects better than GBP field #1. | +| 27 | **PDF deliverables (CMA, disclosure summaries, listing presentations)** | Warm prospect | MED | ✅ | A tracked link/QR inside a CMA PDF tells you the seller re-opened it. | +| 28 | **QR on for-sale window cards / lockbox flyers** | Cold-warm drive-by | MED | ✅ | Same geo logic as yard riders. | +| 29 | **Sphere / past-client touch links (PCFS)** | Warm, known | MED | ⚠️ track-only | Known contacts — retargeting them is low-value; track engagement instead. | + +## The highest-leverage unlocks (where Switchy earns its keep) +1. **Offline → online bridge (postcards, yard riders, open-house QR, mailers).** + This is traffic you literally cannot pixel any other way. A QR scan turns a + physical mail drop into a digital retargeting audience. #17–20, #23, #28. +2. **Non-owned platforms (Zillow, Realtor.com, Nextdoor, GBP posts, social bios).** + You don't control these pages, so the redirect layer is your only pixel hook. +3. **Per-source attribution at scale (newsletter sections, listings, campaigns).** + Tagging every minted link tells you which surface actually drives the audience. diff --git a/skills/switchy-engine/sample_switchy_report.md b/skills/switchy-engine/sample_switchy_report.md new file mode 100644 index 0000000..17ef96f --- /dev/null +++ b/skills/switchy-engine/sample_switchy_report.md @@ -0,0 +1,16 @@ +# Switchy Retargeting Report — 2026-05-28 19:49 + +_Data source: DEMO data (no token found — illustrative numbers)_ +_Model: pixel match 55%, freq 10x / 30d, CPM $22_ + +| Short link | Tags | Destination | Clicks | Audience | Monthly budget | Status | +|---|---|---|---:|---:|---:|---| +| hi.switchy.io/epa-report | newsletter,consumer | https://graehamwatts.com/home-value | 2,140 | 1,177 | $259 | Standalone-ready | +| hi.switchy.io/yt-channel | gbp,youtube,consumer | https://youtube.com/@graehamwatts | 1,880 | 1,034 | $227 | Standalone-ready | +| hi.switchy.io/1908cooley | listing,consumer | https://graehamwatts.com/1908-cooley | 760 | 418 | $92 | Thin — fold into a combined audience | +| hi.switchy.io/oh-flyer-qr | openhouse,qr,consumer | https://graehamwatts.com/1908-cooley | 240 | 132 | $29 | Thin — fold into a combined audience | +| hi.switchy.io/postcard-94303 | postcard,qr,consumer | https://graehamwatts.com/home-value | 95 | 52 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| hi.switchy.io/sig | signature,mixed | https://graehamwatts.com | 60 | 33 | $0 | TOO SMALL — cannot target yet (Meta floor 100) | +| **TOTAL** | | | **5,175** | **2,846** | **$607** | | + +**How to read this:** *Audience* = clicks that resolve to a targetable pixeled user. *Monthly budget* is what it costs to hit that audience 10x over 30 days at $22 CPM — i.e. the spend the audience can actually absorb, not a target. Audiences under 100 can't be targeted; under 1,000 should be merged by source. \ No newline at end of file diff --git a/skills/switchy-engine/scripts/send_email.py b/skills/switchy-engine/scripts/send_email.py new file mode 100644 index 0000000..47dd6a1 --- /dev/null +++ b/skills/switchy-engine/scripts/send_email.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +send_email.py — real SMTP sender for the Switchy weekly report (and any HTML email). +Sends through Gmail using an App Password (NOT the account password), so the Monday +task can actually deliver to the inbox instead of leaving a draft. + +CREDENTIALS (never printed, never committed): +- Sender address: --from or env GMAIL_SENDER (default graehamwatts@gmail.com) +- App password: file C:\\Users\\Graeham Watts\\Documents\\Claude\\Skills\\gmail-app-password.txt + (or --pwfile, or env GMAIL_APP_PASSWORD). This is a 16-char Google + App Password generated at myaccount.google.com/apppasswords. + +USAGE: + python send_email.py --to a@b.com --subject "..." --html-file report.html [--text-file body.txt] +Exit 0 on success; non-zero with a message on failure. +""" +import os, sys, ssl, argparse, smtplib, mimetypes +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from pathlib import Path + +DEFAULT_PWFILE = Path.home().parent / "Graeham Watts" / "Documents" / "Claude" / "Skills" / "gmail-app-password.txt" + + +def load_pw(args): + if args.pwfile and Path(args.pwfile).exists(): + return Path(args.pwfile).read_text(encoding="utf-8").strip().replace(" ", "") + env = os.environ.get("GMAIL_APP_PASSWORD") + if env: + return env.strip().replace(" ", "") + # common locations + for p in [DEFAULT_PWFILE, Path("gmail-app-password.txt"), + Path("/sessions") / os.environ.get("SESSION", "") / "mnt/Skills/gmail-app-password.txt"]: + try: + if p.exists(): + return p.read_text(encoding="utf-8").strip().replace(" ", "") + except OSError: + pass + sys.exit("No Gmail app password found (--pwfile / GMAIL_APP_PASSWORD / gmail-app-password.txt).") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--to", required=True, action="append", help="recipient (repeatable)") + ap.add_argument("--subject", required=True) + ap.add_argument("--html-file") + ap.add_argument("--text-file") + ap.add_argument("--html") + ap.add_argument("--text") + ap.add_argument("--from", dest="sender", default=os.environ.get("GMAIL_SENDER", "graehamwatts@gmail.com")) + ap.add_argument("--pwfile") + ap.add_argument("--attach", action="append", default=[], help="file path to attach (repeatable)") + a = ap.parse_args() + + html = a.html or (Path(a.html_file).read_text(encoding="utf-8") if a.html_file else None) + text = a.text or (Path(a.text_file).read_text(encoding="utf-8") if a.text_file else None) \ + or "Your Switchy report is ready. Open in an HTML-capable client." + pw = load_pw(a) + + # Build the text/html body as a multipart/alternative (this structure delivers + # reliably). Only wrap in multipart/mixed when there are real attachments — + # an empty mixed wrapper was getting dropped by Gmail. + alt = MIMEMultipart("alternative") + alt.attach(MIMEText(text, "plain")) + if html: + alt.attach(MIMEText(html, "html")) + + if a.attach: + msg = MIMEMultipart("mixed") + msg.attach(alt) + for path in a.attach: + p = Path(path) + if not p.exists(): + sys.exit(f"Attachment not found: {path}") + ctype, _ = mimetypes.guess_type(str(p)) + maintype, subtype = (ctype.split("/", 1) if ctype else ("application", "octet-stream")) + part = MIMEBase(maintype, subtype) + part.set_payload(p.read_bytes()) + encoders.encode_base6 \ No newline at end of file diff --git a/skills/switchy-engine/scripts/switchy_analytics.py b/skills/switchy-engine/scripts/switchy_analytics.py new file mode 100644 index 0000000..a43708d --- /dev/null +++ b/skills/switchy-engine/scripts/switchy_analytics.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +switchy_analytics.py — switchy-engine skill core script +========================================================== +Pulls per-link click/scan analytics from the Switchy GraphQL API and turns them +into a retargeting decision table: + + scans/clicks per link -> usable retargeting audience -> ad budget the + audience actually justifies (frequency x CPM model). + +WHY THIS EXISTS +--------------- +Switchy fires Meta/Google/etc. pixels on its redirect layer. Every click on a +Switchy short link (or QR scan -> redirect) drops the visitor into a pixel-based +custom audience BEFORE the destination page even loads. This script reads how big +those audiences are getting per source, so Graeham can decide where to spend. + +SECURITY MODEL (read this) +-------------------------- +- The token is API-key style and scoped to ONE workspace. Treat it like a password. +- It is NEVER hardcoded and NEVER committed. Resolution order: + 1. env var SWITCHY_API_TOKEN + 2. file ~/.switchy/token (chmod 600; gitignored) + 3. file ./.switchy_token (gitignored; local dev only) +- If none found, the script runs in DEMO mode with illustrative numbers so the + output format is reviewable before the live token is active. + +API FACTS (confirmed from developers.switchy.io, May 2026) +---------------------------------------------------------- +- Endpoint: https://graphql.switchy.io/v1/graphql (POST) +- Header: Api-Authorization: +- Queries only on GraphQL; link creation is REST (api.switchy.io/v1/links/create). +- Schema is Hasura-style (where:{field:{_is_null:true}} filter syntax). +- Public docs only document workspace-level fields (workspaces, domains). The + per-link CLICK/SCAN count field name is NOT documented and MUST be confirmed + by introspection on the live token. See confirm_schema() below — run it first. +""" + +import os +import sys +import json +import csv +import argparse +import urllib.request +import urllib.error +from pathlib import Path +from datetime import datetime + +GRAPHQL_ENDPOINT = "https://graphql.switchy.io/v1/graphql" + +# --------------------------------------------------------------------------- +# Tunable economic assumptions for the budget model. Override on the CLI. +# These are deliberately conservative Bay Area / Peninsula real-estate defaults. +# --------------------------------------------------------------------------- +DEFAULTS = { + "cpm": 22.0, # $ per 1,000 impressions, local + interest retargeting + "frequency": 10, # desired impressions per audience member / 30-day window + "min_audience": 100, # Meta hard floor to even target a custom audience + "efficient_audience": 1000, # below this, retargeting is usually inefficient + "pixel_match_rate": 0.55, # share of clicks that resolve to a targetable user + "window_days": 30, +} + + +# --------------------------------------------------------------------------- +# Token handling +# --------------------------------------------------------------------------- +def resolve_token(): + """Return (token, source) or (None, None). Never prints the token.""" + tok = os.environ.get("SWITCHY_API_TOKEN") + if tok: + return tok.strip(), "env:SWITCHY_API_TOKEN" + for p in (Path.home() / ".switchy" / "token", Path(".switchy_token")): + try: + if p.exists(): + return p.read_text(encoding="utf-8").strip(), f"file:{p}" + except OSError: + pass + return None, None + + +# --------------------------------------------------------------------------- +# GraphQL transport +# --------------------------------------------------------------------------- +def gql(query, token, variables=None): + body = json.dumps({"query": query, "variables": variables or {}}).encode("utf-8") + req = urllib.request.Request( + GRAPHQL_ENDPOINT, + data=body, + headers={ + "Content-Type": "application/json", + "Api-Authorization": token, # NB: Switchy uses this, NOT "Authorization: Bearer" + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as r: + payload = json.loads(r.read().decode("utf-8")) + except urllib.error.HTTPError as e: + raise SystemExit(f"[HTTP {e.code}] {e.read().decode('utf-8', 'ignore')[:500]}") + except urllib.error.URLError as e: + raise SystemExit(f"[network] {e.reason}") + if "errors" in payload: + raise SystemExit("[graphql errors] " + json.dumps(payload["errors"], indent=2)) + return payload["data"] + + +# --------------------------------------------------------------------------- +# STEP 1 — Schema confirmation. RUN THIS FIRST on a live token. +# --------------------------------------------------------------------------- +INTROSPECT_LINKS = """ +query ConfirmLinksType { + __type(name: "links") { + name + fields { name description type { name kind ofType { name kind } } } + } +} +""" + +def confirm_schema(token): + """ + Prints the real field names on the `links` type so we can lock the click/scan + field. The public docs DON'T give us this, so this step is mandatory before + trusting the analytics query below. + """ + data = gql(INTROSPECT_LINKS, token) + t = data.get("__type") + if not t: + print("No `links` type found. The top-level type may be named differently " + "(try `link`, `Links`, or run the full __schema introspection). " + "See references/schema-introspection.md.") + return + print(f"Type `{t['name']}` fields:") + for f in t["fields"]: + ty = f["type"] + tyname = ty.get("name") or (ty.get("ofType") or {}).get("name") or ty.get("kind") + print(f" - {f['name']:<28} {tyname}") + print("\nLook for a click/scan count field (e.g. clicks, clicksCount, " + "visits, statistics, *_aggregate) and set --click-field accordingly.") + + +# --------------------------------------------------------------------------- +# STEP 2 — Per-link analytics query. +# +# Two candidate shapes are provided because the exact field name is schema-gated. +# Hasura almost always exposes EITHER a scalar count on the row OR a related +# aggregate. Pick the one confirm_schema() reveals. Default tries the scalar. +# --------------------------------------------------------------------------- +def build_links_query(click_field): + # Scalar-count shape (most common when Switchy denormalizes the counter). + return f""" +query LinkAnalytics {{ + links(order_by: {{clicks: desc}}) {{ + id + domain + url + title + tags + {click_field} + }} +}} +""" + +AGGREGATE_QUERY = """ +query LinkAnalyticsAggregate { + links(order_by: {clicks: desc}) { + id + domain + url + title + tags + clicks_aggregate { aggregate { count } } + } +} +""" + +def fetch_links(token, click_field): + try: + data = gql(build_links_query(click_field), token) + rows = data["links"] + return [_norm(r, click_field) for r in rows] + except SystemExit: + # Fall back to the aggregate relationship shape. + sys.stderr.write(f"[info] scalar field '{click_field}' failed, trying clicks_aggregate...\n") + data = gql(AGGREGATE_QUERY, token) + out = [] + for r in data["links"]: + r = dict(r) + r["_clicks"] = (((r.pop("clicks_aggregate", {}) or {}).get("aggregate") or {}).get("count")) or 0 + out.append(_norm(r, "_clicks")) + return out + + +def _norm(r, click_field): + slug = r.get("id") or "?" + domain = r.get("domain") or "hi.switchy.io" + return { + "short": f"{domain}/{slug}", + "title": r.get("title") or "", + "tags": ",".join(r.get("tags") or []), + "destination": r.get("url") or "", + "clicks": int(r.get(click_field) or 0), + } + + +# --------------------------------------------------------------------------- +# STEP 3 — Audience + budget math +# --------------------------------------------------------------------------- +def audience_and_budget(clicks, cfg): + """clicks -> targetable audience -> monthly budget the audience justifies.""" + audience = int(round(clicks * cfg["pixel_match_rate"])) + impressions = audience * cfg["frequency"] + budget = impressions / 1000.0 * cfg["cpm"] + if audience < cfg["min_audience"]: + status = "TOO SMALL — cannot target yet (Meta floor 100)" + budget = 0.0 + elif audience < cfg["efficient_audience"]: + status = "Thin — fold into a combined audience" + else: + status = "Standalone-ready" + return audience, round(budget, 2), status + + +def build_table(rows, cfg): + out = [] + for r in rows: + aud, bud, status = audience_and_budget(r["clicks"], cfg) + out.append({**r, "audience": aud, "budget": bud, "status": status}) + out.sort(key=lambda x: x["clicks"], reverse=True) + return out + + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- +def render_markdown(table, cfg, source_note): + ts = datetime.now().strftime("%Y-%m-%d %H:%M") + lines = [ + f"# Switchy Retargeting Report — {ts}", + "", + f"_Data source: {source_note}_ ", + f"_Model: pixel match {int(cfg['pixel_match_rate']*100)}%, " + f"freq {cfg['frequency']}x / {cfg['window_days']}d, CPM ${cfg['cpm']:.0f}_", + "", + "| Short link | Tags | Destination | Clicks | Audience | Monthly budget | Status |", + "|---|---|---|---:|---:|---:|---|", + ] + tot_clicks = tot_aud = tot_bud = 0 + for r in table: + dest = (r["destination"][:42] + "…") if len(r["destination"]) > 43 else r["destination"] + lines.append( + f"| {r['short']} | {r['tags']} | {dest} | {r['clicks']:,} | " + f"{r['audience']:,} | ${r['budget']:,.0f} | {r['status']} |" + ) + tot_clicks += r["clicks"]; tot_aud += r["audience"]; tot_bud += r["budget"] + lines += [ + f"| **TOTAL** | | | **{tot_clicks:,}** | **{tot_aud:,}** | **${tot_bud:,.0f}** | |", + "", + "**How to read this:** *Audience* = clicks that resolve to a targetable " + "pixeled user. *Monthly budget* is what it costs to hit that audience " + f"{cfg['frequency']}x over {cfg['window_days']} days at ${cfg['cpm']:.0f} CPM — " + "i.e. the spend the audience can actually absorb, not a target. Audiences " + "under 100 can't be targeted; under 1,000 should be merged by source.", + ] + return "\n".join(lines) + + +def write_csv(table, path): + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=["short", "title", "tags", "destination", + "clicks", "audience", "budget", "status"]) + w.writeheader() + w.writerows(table) + + +# --------------------------------------------------------------------------- +# Demo data (used only when no token is available) +# --------------------------------------------------------------------------- +DEMO_ROWS = [ + {"short": "hi.switchy.io/epa-report", "title": "EPA Report newsletter CTA", "tags": "newsletter,consumer", + "destination": "https://graehamwatts.com/home-value", "clicks": 2140}, + {"short": "hi.switchy.io/yt-channel", "title": "GBP -> YouTube channel", "tags": "gbp,youtube,consumer", + "destination": "https://youtube.com/@graehamwatts", "clicks": 1880}, + {"short": "hi.switchy.io/1908cooley", "title": "1908 Cooley single-property", "tags": "listing,consumer", + "destination": "https://graehamwatts.com/1908-cooley", "clicks": 760}, + {"short": "hi.switchy.io/oh-flyer-qr", "title": "Open house flyer QR", "tags": "openhouse,qr,consumer", + "destination": "https://graehamwatts.com/1908-cooley", "clicks": 240}, + {"short": "hi.switchy.io/postcard-94303", "title": "94303 farm postcard QR", "tags": "postcard,qr,consumer", + "destination": "https://graehamwatts.com/home-value", "clicks": 95}, + {"short": "hi.switchy.io/sig", "title": "Email signature", "tags": "signature,mixed", + "destination": "https://graehamwatts.com", "clicks": 60}, +] + + +def main(): + ap = argparse.ArgumentParser(description="Switchy per-link retargeting analytics.") + ap.add_argument("--confirm-schema", action="store_true", + help="Introspect the `links` type and exit. RUN THIS FIRST on a live token.") + ap.add_argument("--click-field", default="clicks", + help="Scalar click/scan count field on the links type (confirm via --confirm-schema).") + ap.add_argument("--cpm", type=float, default=DEFAULTS["cpm"]) + ap.add_argument("--frequency", type=int, default=DEFAULTS["frequency"]) + ap.add_argument("--pixel-match-rate", type=float, default=DEFAULTS["pixel_match_rate"]) + ap.add_argument("--out", default="switchy_report") + args = ap.parse_args() + + cfg = dict(DEFAULTS, cpm=args.cpm, frequency=args.frequency, + pixel_match_rate=args.pixel_match_rate) + + token, source = resolve_token() + + if args.confirm_schema: + if not token: + raise SystemExit("No token found. Set SWITCHY_API_TOKEN or ~/.switchy/token first.") + confirm_schema(token) + return + + if token: + rows = fetch_links(token, args.click_field) + source_note = f"LIVE Switchy API ({source})" + else: + rows = DEMO_ROWS + source_note = "DEMO data (no token found — illustrative numbers)" + sys.stderr.write( + "\n[!] No Switchy token found — running in DEMO mode.\n" + " To go live: get the token from Switchy (Workspace > Settings >\n" + " Integrations > Generate a token; you may need to ask Switchy live\n" + " chat to enable API access first), then:\n" + " export SWITCHY_API_TOKEN=xxxx (mac/linux)\n" + " setx SWITCHY_API_TOKEN xxxx (windows)\n" + " Then re-run with --confirm-schema to lock the click field name.\n\n") + + table = build_table(rows, cfg) + md = render_markdown(table, cfg, source_note) + Path(args.out + ".md").write_text(md, encoding="utf-8") + write_csv(table, args.out + ".csv") + print(md) + print(f"\n[written] {args.out}.md and {args.out}.csv") + + +if __name__ == "__main__": + main() diff --git a/skills/switchy-engine/scripts/switchy_dashboard.py b/skills/switchy-engine/scripts/switchy_dashboard.py new file mode 100644 index 0000000..c977100 --- /dev/null +++ b/skills/switchy-engine/scripts/switchy_dashboard.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +switchy_dashboard.py — unified Switchy clicks dashboard +========================================================= +Pulls every link's click count from the Switchy GraphQL API, groups by the link's +Switchy FOLDER (= traffic source: postcards, GMB, YouTube, yard signs, ads, ...), +snapshots for week-over-week deltas, and renders ONE branded HTML dashboard for +all Switchy clicks. + +WHY GROUP BY FOLDER: the Switchy API exposes only a running `clicks` total per link +(no referrer/geo/device/time-series). But the folder a link lives in already encodes +its source, so folder-level grouping answers "where are the clicks coming from?" +even for links that were never tagged. Tags refine this further when present. + +WEEK-OVER-WEEK: the API has no history, so we snapshot all click counts to a dated +JSON each run and diff against the most recent prior snapshot. First run = baseline +(deltas show from run #2 onward). + +USAGE +----- + export SWITCHY_API_TOKEN=... # or ~/.switchy/token + python switchy_dashboard.py --outdir [--snapdir ] + +Writes: /index.html and /switchy-snapshot-YYYY-MM-DD.json +""" +import os, sys, json, argparse, urllib.request, urllib.error, glob +from pathlib import Path +from datetime import datetime, date + +GRAPHQL = "https://graphql.switchy.io/v1/graphql" +MODEL = {"pixel_match_rate": 0.55, "frequency": 10, "cpm": 22.0, + "min_audience": 100, "efficient_audience": 1000, "window_days": 30} +GOLD, INK, CREAM = "#C2A14E", "#1A1D2E", "#FBF7EC" + + +def token(): + t = os.environ.get("SWITCHY_API_TOKEN") + if t: + return t.strip() + for p in (Path.home()/".switchy"/"token", Path(".switchy_token")): + if p.exists(): + return p.read_text().strip() + sys.exit("No SWITCHY_API_TOKEN found (env or ~/.switchy/token).") + + +def gql(q, tok): + req = urllib.request.Request(GRAPHQL, data=json.dumps({"query": q}).encode(), + headers={"Content-Type": "application/json", + "Api-Authorization": tok}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + d = json.loads(r.read().decode()) + if "errors" in d: + sys.exit("GraphQL error: " + json.dumps(d["errors"])[:400]) + return d["data"] + + +def audience(clicks): + return int(round(clicks * MODEL["pixel_match_rate"])) + + +def budget(aud): + if aud < MODEL["min_audience"]: + return 0.0 + return round(aud * MODEL["frequency"] / 1000.0 * MODEL["cpm"], 0) + + +def load_prior(snapdir): + files = sorted(glob.glob(os.path.join(snapdir, "switchy-snapshot-*.json"))) + if not files: + return None, None + f = files[-1] + try: + return json.load(open(f)), os.path.basename(f) + except Exception: + return None, None + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--outdir", required=True) + ap.add_argument("--snapdir", default=None) + a = ap.parse_args() + outdir = a.outdir + snapdir = a.snapdir or os.path.join(outdir, "snapshots") + Path(outdir).mkdir(parents=True, exist_ok=True) + Path(snapdir).mkdir(parents=True, exist_ok=True) + + tok = token() + data = gql("{ links(order_by:{clicks:desc}){ id domain url title tags clicks folderId } " + "folders { id name } }", tok) + links = data["links"] + fname = {f["id"]: f["name"].strip() for f in data["folders"]} + + # snapshot + today = date.today().isoformat() + snap = {"date": today, "clicks": {l["id"]: l["clicks"] for l in links}} + prior, prior_name = load_prior(snapdir) + json.dump(snap, open(os.path.join(snapdir, f"switchy-snapshot-{today}.json"), "w")) + + def delta(lid, cur): + if not prior: + return None + return cur - prior["clicks"].get(lid, 0) + + # group by folder (= source) + groups = {} + tot_clicks = tot_aud = tot_bud = tot_delta = 0 + for l in links: + src = fname.get(l["folderId"], "Unfiled / no source tag") + g = groups.setdefault(src, {"clicks": 0, "links": 0, "delta": 0, "rows": []}) + g["clicks"] += l["clicks"]; g["links"] += 1 + d = delta(l["id"], l["clicks"]) + if d: + g["delta"] += d; tot_delta += d + g["rows"].append(l) + tot_clicks += l["clicks"] + for s, g in groups.items(): + g["aud"] = audience(g["clicks"]); g["bud"] = budget(g["aud"]) + tot_aud += g["aud"] + tot_bud = budget(tot_aud) + ranked = sorted(groups.items(), key=lambda kv: kv[1]["clicks"], reverse=True) + + # top links + top = links[:15] + + # ---- render ---- + def fmt(n): + return f"{n:,}" + week_note = (f"vs. {prior['date']} ({prior_name})" if prior + else "baseline — week-over-week deltas begin next run") + src_labels = json.dumps([s for s, _ in ranked]) + src_clicks = json.dumps([g["clicks"] for _, g in ranked]) + + rows_src = "\n".join( + f"{s}{fmt(g['links'])}{fmt(g['clicks'])}" + f"{('+' if g['delta']>0 else '')+fmt(g['delta']) if prior else '—'}" + f"{fmt(g['aud'])}${fmt(int(g['bud']))}" + for s, g in ranked) + + rows_top = "\n".join( + f"{(l['title'] or l['id'])[:46]}" + f"{fname.get(l['folderId'],'—')}" + f"{l['domain']}/{l['id']}" + f"{fmt(l['clicks'])}" + f"{('+' if (delta(l['id'],l['clicks']) or 0)>0 else '')+fmt(delta(l['id'],l['clicks'])) if prior else '—'}" + for l in top) + + html = f""" + +Switchy Clicks Dashboard — Graeham Watts + + +

Switchy Clicks Dashboard ALL SOURCES

+
Generated {datetime.now():%Y-%m-%d %H:%M} · {week_note} · model: 55% match · {MODEL['frequency']}×/30d · ${int(MODEL['cpm'])} CPM
+ +
+
{fmt(tot_clicks)}
Total clicks / scans
+
{('+'+fmt(tot_delta)) if prior else '—'}
New this week
+
{fmt(tot_aud)}
Targetable audience
+
${fmt(int(tot_bud))}
Justified ad budget / mo
+
+ +
+

Where the clicks come from (by Switchy folder)

+ +
+ +
+

Sources breakdown

+ + + {rows_src} + +
Source (folder)LinksClicksNew/wkAudienceBudget/mo
TOTAL{fmt(len(links))}{fmt(tot_clicks)}{('+'+fmt(tot_delta)) if prior else '—'}{fmt(tot_aud)}${fmt(int(tot_bud))}
+
+ +
+

Top 15 links

+ + + {rows_top} +
LinkSourceShort URLClicksNew/wk
+
+ +
+ How to read this: Audience = clicks that resolve to a targetable pixeled user (55%). Budget/mo = what that audience can absorb at {MODEL['frequency']}×/30d, ${int(MODEL['cpm'])} CPM — a ceiling, not a target. Sources are the Switchy folders each link lives in; "Unfiled" links need a folder/tag to be attributable. Switchy's API gives click totals only — geo/referrer/device live in GA4 (via UTM) and Meta (via pixel). +
+ + +""" + + out = os.path.join(outdir, "index.html") + Path(out).write_text(html, encoding="utf-8") + print(f"[dashboard] {out}") + print(f"[totals] clicks={tot_clicks} audience={tot_aud} budget=${int(tot_bud)} sources={len(groups)} links={len(links)}") + print(f"[snapshot] {os.path.join(snapdir, f'switchy-snapshot-{today}.json')} (prior: {prior_name or 'none'})") + + +if __name__ == "__main__": + main() diff --git a/skills/switchy-qr/SKILL.md b/skills/switchy-qr/SKILL.md new file mode 100644 index 0000000..07947ca --- /dev/null +++ b/skills/switchy-qr/SKILL.md @@ -0,0 +1,63 @@ +--- +name: switchy-qr +description: "Generate a tracked QR code for a real-estate postcard via Switchy. Built for Peter (Jason) — the ONLY job is: take a finished postcard and produce a scannable QR code that points to a tracked Switchy short link (with the right landing page, UTM, folder, and retargeting pixels already set), then hand back the QR PNG to embed in the postcard. Use this skill ANY time the user says: generate a QR code for this postcard, make a postcard QR, QR for the mailer, tracked QR, Switchy QR, create the postcard link, or uploads a postcard and asks for a QR. This is the lightweight QR-only companion to the full switchy-engine skill (analytics/dashboard live there, not here)." +--- + +# Switchy QR (postcard QR generator) — for Peter / Jason + +**What this does, in one line:** you finish a postcard, say *"generate a QR code for +this postcard,"* and Claude creates a tracked Switchy link + QR for you to drop into +the design. Switchy then tracks every scan and adds scanners to Graeham's ad audience. + +**You only do two things:** (1) say the command + upload the postcard, and (2) log +into Switchy once when asked. Claude does the rest. + +--- + +## One-time setup + +You need access to Graeham's Switchy. Either: +- **Your own login** — Graeham adds you as a team member in Switchy (Account → Team), + OR +- **The shared login** Graeham gives you. + +And the Switchy API token so Claude can create the link automatically. Graeham will +provide it; save it in a file named `switchy-token.txt` in your Cowork Skills folder, +OR set an environment variable `SWITCHY_API_TOKEN`. (Never paste the token into chat.) + +--- + +## How to generate a QR (every postcard) + +1. Finish the postcard in Canva. +2. In Cowork say: **"Generate a QR code for this postcard"** and upload the postcard + (PDF or image). Tell Claude the **mail date** (e.g. 2026-06-01) and, if it's not the + usual home-value report, the **destination** (default is the home-value page). +3. Claude runs `scripts/create_postcard_link.py` to create the tracked link — correct + landing page + UTM + the **"Post card qr"** folder + Graeham's Meta/Google pixels, + all set automatically. +4. Claude opens Switchy in Chrome. **You log in once** when prompted (Claude can't type + passwords or solve the "I'm not a robot" check — that part is you). +5. Claude finds the new link, opens its QR, clicks **Download as PNG**, and gives you + the QR file. +6. Drop the QR into the Canva postcard, export, done. (The QR keeps working even if the + landing page changes later — it's swappable on Switchy's side.) + +--- + +## What Claude runs to make the link + +```bash +python scripts/create_postcard_link.py --date 2026-06-01 --hook "Last 5 Homes" +# optional: --dest https://graehamwatts.com/evaluation --market epa --archetype anti_zestimate +``` +It prints the short URL (e.g. `hi.switchy.io/epa-comps-0601`). Then Claude downloads the +QR for that link from the Switchy dashboard (Chrome). See the script header for details. + +## Naming (handled automatically — just so you recognize it in Switchy) +- Link title: `Postcard (home value)` +- Folder: `Post card qr` · Tags: `postcard, qr, consumer, , ` +- UTM: `utm_source=postcard&utm_medium=direct_mail&utm_campaign=_&utm_content=` + +> Need scan numbers, the dashboard, or retargeting reports? That's the full +> **switchy-engine** skill (Graeham's). This skill is QR-only on purpose. diff --git a/skills/switchy-qr/scripts/create_postcard_link.py b/skills/switchy-qr/scripts/create_postcard_link.py new file mode 100644 index 0000000..aef2ee8 --- /dev/null +++ b/skills/switchy-qr/scripts/create_postcard_link.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +create_postcard_link.py — make ONE tracked Switchy link for a postcard QR. + +Built for Peter (Jason). Creates the link via Switchy's REST API with the correct +landing page + UTM + folder + Graeham's retargeting pixels already attached, then +prints the short URL. Claude then downloads the QR for that link from the Switchy +dashboard in Chrome. + +TOKEN (never printed): env SWITCHY_API_TOKEN, or a file switchy-token.txt in the +Skills folder, or ~/.switchy/token. + +USAGE: + python create_postcard_link.py --date 2026-06-01 --hook "Last 5 Homes" + optional: --dest --market epa --archetype anti_zestimate --slug epa-comps-0601 +""" +import os, sys, json, argparse, urllib.request, urllib.error +from pathlib import Path + +REST = "https://api.switchy.io/v1/links/create" +DOMAIN = "hi.switchy.io" +POSTCARD_FOLDER_ID = 92811 # Switchy "Post card qr" folder +DEFAULT_DEST = "https://graehamwatts.com/evaluation" # home-value report +# Graeham's pixels (so scanners enter the retargeting audience automatically) +PIXELS = [ + {"platform": "facebook", "value": "963211690980393"}, + {"platform": "ga", "value": "G-S82GF32XJT"}, + {"platform": "adwords", "value": "AW-1047225119"}, +] + + +def token(): + t = os.environ.get("SWITCHY_API_TOKEN") + if t: + return t.strip() + here = Path(__file__).resolve() + candidates = [ + Path.cwd() / "switchy-token.txt", + Path.home() / ".switchy" / "token", + ] + # walk up to find a Skills/switchy-token.txt + for parent in here.parents: + candidates.append(parent / "switchy-token.txt") + if parent.name == "Skills": + candidates.append(parent / "switchy-token.txt") + for p in candidates: + try: + if p.exists(): + return p.read_text(encoding="utf-8").strip() + except OSError: + pass + sys.exit("No Switchy token (set SWITCHY_API_TOKEN or place switchy-token.txt in the Skills folder).") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--date", required=True, help="mail date YYYY-MM-DD, e.g. 2026-06-01") + ap.add_argument("--hook", required=True, help='short hook, e.g. "Last 5 Homes"') + ap.add_argument("--dest", default=DEFAULT_DEST) + ap.add_argument("--market", default="epa") + ap.add_argument("--archetype", default="anti_zestimate") + ap.add_argument("--slug", default=None) + a = ap.parse_args() + + mmddyy = "".join(a.date.split("-")[::-1][:2][::-1]) # -> keeps mmdd? build explicitly: + y, m, d = a.date.split("-") + mmddyy = f"{m}_{d}_{y[2:]}" + slug = a.slug or f"{a.market}-comps-{m}{d}" + dest = a.dest + ("&" if "?" in a.dest else "?") + \ + f"utm_source=postcard&utm_medium=direct_mail&utm_campaign={a.market}_{mmddyy}&utm_content={a.archetype}" + title = f"Postcard {a.market.upper()} {a.date} — {a.hook} (home value)" + tags = ["postcard", "qr", "consumer", a.market, a.date] + + payload = {"link": {"url": dest, "id": slug, "title": title, "folderId": POSTCARD_FOLDER_ID, + "tags": tags, "showGDPR": False, "pixels": PIXELS}, "autofill": False} + req = urllib.request.Request(REST, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Api-Authorization": token()}, method="POST") + try: + r = json.loads(urllib.request.urlopen(req, timeout=30).read()) + except urllib.error.HTTPError as e: + sys.exit(f"Create failed: HTTP {e.code} {e.read().decode('utf-8','ignore')[:300]}") + short = f"https://{r.get('domain', DOMAIN)}/{r.get('id', slug)}" + print("SHORT URL :", short) + print("DEST :", dest) + print("FOLDER : Post card qr (92811)") + print("PIXELS : facebook, ga, adwords") + print("\nNEXT: in Switchy (Chrome) → find this link → Download QR Code → Download as PNG → embed in the postcard.") + + +if __name__ == "__main__": + main() diff --git a/skills/transcript-repurposer/EDITOR_ONBOARDING.md b/skills/transcript-repurposer/EDITOR_ONBOARDING.md new file mode 100755 index 0000000..83e3a26 --- /dev/null +++ b/skills/transcript-repurposer/EDITOR_ONBOARDING.md @@ -0,0 +1,213 @@ +# Transcript Repurposer — How to Use It + +For Jason and Ellie (and any future editor on the Watts content team). + +This is your quick-start guide. From "I have a video URL" to "here are 10 deliverables ready to edit," in 3-5 minutes per video. + +--- + +## What this thing does + +You give it a URL to any video on the internet — Instagram Reel, TikTok, YouTube Short, podcast, whatever. It does this in one go: + +1. Downloads the audio +2. Transcribes it (free Whisper by default, or Deepgram premium if Graeham flips that on) +3. Analyzes what the original video was actually trying to say +4. Decides how to make Graeham's version better +5. Adds Bay Area / real estate research where it fits +6. Writes 3 new hooks, scored, with a recommendation +7. Writes scripts for YouTube Long, YouTube Short, IG Reel, TikTok, Blog, GMB, Facebook +8. Generates HeyGen avatar script (paste-ready) + Higgsfield B-roll prompts + ElevenLabs voice markup +9. Humanizes everything so it doesn't sound AI-written +10. Delivers a folder with one downloadable file per artifact + an HTML preview + +You get back a folder. You open `index.html` in your browser. You see everything in a clean dashboard. You download whichever file you need. + +--- + +## How to run it — the real workflow + +**Hard reality first:** Cowork can't reach YouTube/Instagram/TikTok/Deepgram directly. Transcription happens on your local machine via a small CLI tool, then Cowork picks up the result. Setup is one-time and takes ~5 minutes per machine. After that, daily use is fast. + +### One-time setup on your Windows machine + +Follow `scripts/SETUP_LOCAL_CLI.md`. Quick version: + +1. Install Python 3.10+ (check "Add to PATH" during install) +2. `pip install yt-dlp httpx` +3. Install ffmpeg, add it to PATH +4. Save the Deepgram key (Graeham gives you this) to `Documents\Claude\Skills\deepgram-key.txt` +5. Copy `transcribe.bat` and `transcribe_local.py` somewhere convenient + +### Daily workflow + +Three steps: + +**1. In a terminal on your computer:** + +``` +transcribe https://www.youtube.com/watch?v=... +``` + +You'll see progress for 30-60 seconds. When done, it tells you the transcript landed in your inbox folder. + +**2. In Cowork:** + +``` +Repurpose the latest from my inbox +``` + +The skill reads the newest transcript from `Documents\Claude\Skills\_inbox\`, runs the full pipeline, delivers the artifact bundle. + +**3. Open the resulting `index.html`** in your browser to see the dashboard with all the downloadable files. + +### Direct alternatives (when the URL path isn't right) + +**Already have a transcript** (pasted or from another tool): +``` +Repurpose this transcript: [paste it, or attach the .txt/.srt file] +``` + +**Want me to use YouTube's built-in transcript panel** (zero local setup, only works for YouTube): +``` +Use Claude in Chrome to grab the YouTube transcript: https://... +``` + +Then say "repurpose it." Slower than the CLI route but no local install needed. + +**Only want certain outputs:** +``` +Repurpose the latest from my inbox and just give me the IG Reel and TikTok +``` + +--- + +## What you get back + +A folder named something like `transcript-repurpose-bay-area-mortgage-rates-20260516-1015\` in either: + +- `Documents\Claude\online-content\repurpose\` (for published team work) +- The session outputs folder (for drafts) + +Inside the folder: + +| File | What it is | When to use it | +|---|---|---| +| `index.html` | The dashboard view — open in browser | **Start here every time.** Preview the whole package, click downloads | +| `transcript.txt` | Raw transcript of the source video | If you want the original words for reference | +| `content-package.md` | Everything in one markdown | If you want the master copy | +| `hooks.md` | 3 hook variants + the recommendation | When deciding between hook angles for A/B testing | +| `script-yt-long.md` | Long-form YouTube script with inline shot tags | For full YouTube production | +| `script-yt-short.md` | 30-60 sec Shorts script | YouTube Shorts | +| `script-ig-reel.md` | 30-60 sec Reel script with caption overlay tags | Instagram Reels | +| `script-tiktok.md` | TikTok script | TikTok posts | +| `script-blog.md` | 800-1200 word blog post, SEO-tuned | Website / newsletter content | +| `captions.md` | All per-platform captions + hashtags | Drop into the platform when posting | +| `ssml.xml` | ElevenLabs voice markup (XML) | When generating voice in ElevenLabs | +| `heygen-script.txt` | Clean script with NO shot tags — for the avatar mouth | Paste directly into HeyGen | +| `broll-prompts.md` | Higgsfield B-roll prompts (image + motion) | For Jason — generates the B-roll clips | +| `editing-notes.md` | Shot list, text overlay timing, pacing notes, thumbnail concept | **Jason — this is yours.** Your editing brief | +| `manifest.json` | Index of all files + metadata | For automated tooling later | + +--- + +## The HTML preview (`index.html`) + +When you open `index.html`, you see: + +- The video title, source URL, platform, duration, word count, transcription tier used +- A grid of all the artifact files with download buttons +- The hook variants block (so you can read them without opening a separate file) +- A scrollable full-package preview with a "Copy full package" button + +You don't need to be online for the HTML to work. It's a self-contained file. Open it from File Explorer — double-click `index.html` and your default browser will show it. + +--- + +## Typical workflow for repurposing one video + +For Jason (video editor): + +1. Graeham (or you) drops a URL into Cowork. Skill runs. Folder lands in `online-content\repurpose\`. +2. Open `index.html`. Glance at the hooks. Read the editing notes. +3. Download `heygen-script.txt`. Paste into HeyGen, render the avatar video. +4. Download `broll-prompts.md`. Use the prompts in Higgsfield to generate B-roll clips. The prompts are paired (one image prompt + one motion prompt per shot). +5. Download `ssml.xml`. Use it in ElevenLabs if generating voice separately from HeyGen. +6. Open `editing-notes.md` in your editor of choice. That's your brief — shot list, timing, thumbnail concept. +7. Edit. Publish. + +For Ellie (social media / captions): + +1. Open `index.html`. Read the hooks. +2. Download `captions.md`. Each section has the caption for its platform plus hashtags. +3. Open the right platform script (`script-ig-reel.md`, `script-tiktok.md`) for the caption-overlay text. +4. Post. + +--- + +## When things go wrong + +**"yt-dlp download failed" / "URL is private or geo-blocked"** + +The video can't be downloaded. Three options: + +1. Check if the post is public (private Instagram or unlisted YouTube fails) +2. Try a different URL for the same content (sometimes a YouTube version exists where the Instagram one doesn't) +3. Use SurfFast or Unmixr to get the transcript manually, then paste it into Cowork + +**Transcript looks garbled or has wrong words** + +Whisper occasionally misses on accents or fast speech. Either: +- Re-run at premium quality (`Repurpose this at premium quality: `) — Deepgram is more accurate +- Edit `transcript.txt` manually and re-run the skill with the cleaned transcript + +**The hook recommendation doesn't fit Graeham's voice** + +The 3 variants are options. If the recommended one doesn't land, use one of the other two — or ask Cowork: + +``` +Try a different hook for this — the recommended one is too [hype/corporate/generic]. Use a [story / contrarian / personal] angle. +``` + +**You only need one derivative** + +Tell the skill upfront. "Just give me the IG Reel" or "skip the blog and YouTube Long." Saves time. + +--- + +## Quality tiers — when to flip premium + +**Default (Whisper local — free):** +- 30-90 sec social videos +- Most content with clear speech, no heavy accents +- When you're prototyping multiple versions of a topic + +**Premium (Deepgram — paid):** +- Podcasts over 15 min +- Accented speakers or noisy audio +- Important client testimonials where every word matters +- When you need the script delivered in under a minute, not 5 + +If you're not sure, default is fine. You can always re-run at premium. + +--- + +## Cost expectations + +For your reference (Graeham covers this): + +| Tier | Per-video cost | Typical use | +|---|---|---| +| Whisper local | $0 | 80-90% of jobs | +| Deepgram (60-sec reel) | ~$0.005 | When quality matters | +| Deepgram (5-min podcast clip) | ~$0.02 | Heavy-accent or important content | +| Deepgram (30-min podcast) | ~$0.13 | Long-form repurpose | + +Run premium whenever you genuinely need it — Graeham would rather spend $0.13 than have a transcription error make it to publish. + +--- + +## Where to ask for help + +- **Skill won't fire / unclear what to type:** Ping Graeham +- **Output is missing a section:** Open `content-package.md` and check if Phase 6 produced that derivative. If it didn't, the input transcript may have been too \ No newline at end of file diff --git a/skills/transcript-repurposer/README.md b/skills/transcript-repurposer/README.md new file mode 100755 index 0000000..9694ae7 --- /dev/null +++ b/skills/transcript-repurposer/README.md @@ -0,0 +1,44 @@ +# Transcript Repurposer + +The fast-lane sibling of `video-script-creation-engine`. Takes a transcript Graeham already has (SurfFast download, paste, .srt, .vtt) and produces a Graeham-voiced, data-backed, humanized content package across all platforms — YouTube Long, Short, IG Reel, TikTok, Carousel, Blog, GMB, Facebook — plus HeyGen and Higgsfield production handoff blocks. + +## Why this skill exists + +SurfFast downloads + auto-transcribes videos. Great for capturing source material. But the resulting transcripts have no data backbone — no Bay Area market stats, no AB 1482 references, no verified facts. Repurposing directly from SurfFast output produced karaoke versions of someone else's video, missing the depth that makes Graeham's Content Engine scripts feel authoritative. + +This skill fixes that. Same script-quality pipeline as the Content Engine, but transcript-first instead of ideation-first. + +## Pipeline + +| Phase | What it does | Reference | +|---|---|---| +| 1 | Ingest the transcript (any format) | `references/01-ingest.md` | +| 2 | Analyze the source — 7-field source brief | `references/02-analyze.md` | +| 3 | Decide the repurpose angle (5 angles, optional hybrid) | `references/03-angle.md` | +| 4 | Inject research and data (this is the "fix" — what SurfFast doesn't give us) | `references/04-research.md` | +| 5 | Generate 3 hook variants from 3 frameworks, scored | `references/hook-frameworks.md` | +| 6 | Multi-platform script writing with inline shot direction tags | `references/06-script-writing.md` | +| 7 | HeyGen + Higgsfield + ElevenLabs production handoff | `references/07-handoff.md` | +| 8 | Humanizer auto-pass (required, not optional) | `references/08-humanizer.md` | + +## When this fires vs. video-script-creation-engine + +**This skill** = the user already has the transcript. SurfFast download, manual paste, .srt file, anything text. + +**Content Engine** = the user has a topic or wants ideas; no source video. The Content Engine generates from audience research. + +## Integration with other skills + +- **Pulls from:** `video-script-creation-engine` (voice and style refs, market config, GHL keyword set) +- **Auto-invokes:** `humanizer` (final pass on the content package) +- **Hands off to:** `heygen-video` (avatar render), `higgsfield-video` (B-roll generation), `vaibhav-template` (if Graeham wants the Vaibhav aesthetic on top) + +## Output + +Single content-package markdown file saved to `outputs/transcript-repurpose-{slug}-{YYYYMMDD-HHMM}.md`. + +## Quickstart + +User pastes a transcript or uploads a transcript file → say "repurpose this for me" → the skill runs all 8 phases and delivers the package. + +User can also request only specific derivatives: "just give me the IG Reel" or "I only need the YouTube Short and the caption." The skill scales down accordingly. diff --git a/skills/transcript-repurposer/SKILL.md b/skills/transcript-repurposer/SKILL.md new file mode 100755 index 0000000..509a81c --- /dev/null +++ b/skills/transcript-repurposer/SKILL.md @@ -0,0 +1,218 @@ +--- +name: transcript-repurposer +description: "Transcript-to-script repurposing engine for Graeham Watts. Takes EITHER a video URL (Instagram, TikTok, YouTube, Vimeo, podcast, anything yt-dlp supports — auto-transcribed via the shared transcription module) OR an existing transcript (SurfFast download, .srt, .vtt, .txt, paste) and rebuilds it as a Graeham-voiced, data-backed, multi-platform content package with stronger hooks, real research, and HeyGen + Higgsfield handoff prompts. Use ANY time the user mentions: repurpose this video, rewrite this Instagram, rewrite this TikTok, rewrite this YouTube, take this script and make it mine, redo this hook, transcribe and repurpose, SurfFast, downloaded transcript, transcribed video, transcript file, .srt, .vtt, subtitle file, 'I downloaded this', or hands over a video URL and wants a Graeham-style version. Also trigger when the user uploads a .txt / .srt / .vtt / .mp3 / .mp4 file and asks for a rewritten script. Auto-transcribes URLs by default — no manual paste needed. Auto-runs the humanizer skill on the final output. This skill is the CORRECT CHOICE for repurposing existing content — do not use content-creation-engine (that's for original content from scratch)." +--- + +# Transcript Repurposer + +Take a transcript from anywhere — SurfFast download, manual paste, .srt file, .vtt file, podcast transcript, YouTube auto-captions — and turn it into a Graeham-voiced, data-backed content package with stronger hooks, real research grounding, and production-ready handoff to HeyGen and Higgsfield. + +This skill is the **transcript-first cousin** of `video-script-creation-engine`. The Content Engine builds scripts from scratch starting at audience demand research. This skill starts from an existing transcript and rebuilds it — same data/research quality, but skipping the BOFU query generator and Reddit ideation phases because the source video already supplies the topic and angle. + +## When this skill fires vs. video-script-creation-engine + +| Situation | Use | +|---|---| +| User pastes a transcript or uploads .srt/.vtt/.txt and wants their own version | **This skill** | +| User says "I downloaded this Instagram, rewrite it for me" | **This skill** | +| User says "SurfFast pulled this — make it mine" | **This skill** | +| User says "give me content ideas for this week" | `video-script-creation-engine` | +| User says "write me a script about AB 1482" (no source video) | `video-script-creation-engine` | +| User has a YouTube URL but NO transcript yet | `video-script-creation-engine` (it has its own YouTube transcriber in Phase 0) | + +The distinguishing signal: **does the user already have the words?** If yes, this skill. If no, the Content Engine. + +## The 10-Phase Pipeline (Phase 0 is the auto-transcription entry point; Phase 9 is the final delivery) + +Run these in order. Don't skip ahead. Each phase has a clear input and output so you can hand off to the next phase cleanly. + +### Phase 0 — Ingest the Source + +**Read:** `references/00-auto-transcribe.md` for the full ingestion logic and the THREE accepted entry points. + +**Hard reality:** The Cowork bash sandbox cannot reach YouTube, Instagram, TikTok, Deepgram, or any external transcription API. Network egress is allowlisted to only github.com and pypi.org. So Phase 0 does NOT perform transcription inside Cowork. Transcription happens via one of three entry points, all OUTSIDE the sandbox. + +**Three entry points (pick based on what the user provides):** + +**A. User provides transcript directly** — paste, .txt, .srt, .vtt. Read it, skip to Phase 1. + +**B. User has run the local CLI and dropped a transcript in `Documents\Claude\Skills\_inbox\`** — read the latest `.json` manifest and paired `.txt` from the inbox folder. This is the recommended agentic path for the Watts team. Trigger: "Repurpose the latest from my inbox." + +**C. User hands me a raw URL** — I cannot transcribe it inside Cowork. Three honest fallbacks: +- **C1 (YouTube only):** Drive Claude in Chrome to YouTube's built-in transcript panel. ~30 sec. +- **C2 (anything):** Tell user to run the local CLI: `transcribe `. Result lands in their inbox; then user says "run the inbox." +- **C3 (fallback):** Suggest SurfFast or Unmixr to get the text, then paste it back. + +**Local CLI:** `scripts/transcribe_local.py` + `transcribe.bat` run on the user's actual Windows machine (not the sandbox). They use yt-dlp + Deepgram Nova-3 (with the saved key) and drop transcripts into the inbox folder. Setup instructions: `scripts/SETUP_LOCAL_CLI.md`. + +**Output of Phase 0:** `source_text` string + `source_metadata` dict (platform, title, uploader, duration, word_count, tier, entry_point). Pass to Phase 1. + +### Phase 1 — Ingest the Transcript + +**Read:** `references/01-ingest.md` for full input handling. + +Accept any of these inputs and normalize to a single clean text block: + +- **Pasted text** — strip any quote markers, timestamps, speaker labels +- **`.txt` file** — read as-is +- **`.srt` / `.vtt` subtitle file** — strip timing cues and sequence numbers, keep only spoken text +- **`.json` from SurfFast** — extract the `text` field, ignore metadata unless it tells us the source platform (which we want for context) +- **Audio file (`.mp3`, `.wav`, `.m4a`)** — tell the user we need a transcript first. SurfFast downloads audio but doesn't transcribe; for transcription, point to the Content Engine's `youtube_transcriber.py` Whisper script or have the user paste a transcript. + +**Output of Phase 1:** A clean `source_text` string and a one-paragraph `source_metadata` (platform if known, approximate length, any creator/handle visible). + +### Phase 2 — Analyze the Source + +**Read:** `references/02-analyze.md` for the analysis framework. + +Decompose the source transcript along these axes so we know what to keep, what to throw out, and what to upgrade: + +1. **Core claim** — In one sentence, what is the video actually saying? +2. **Hook strategy used** — What pattern interrupt did the original creator open with? (See `references/hook-frameworks.md` for the 8 patterns.) +3. **Evidence quality** — Does the source cite data, anecdotes, or just opinion? Flag any specific numbers, dates, or claims that need fact-checking. +4. **Structure** — Hook → setup → payoff → CTA? Or list format? Or story format? Tag the structure. +5. **Target audience signal** — Who is the original creator talking to? First-time buyers? Investors? Renters? General audience? +6. **Length and pace** — Word count, approximate spoken duration, density of ideas per 30 seconds. +7. **Localization need** — Is this topic real-estate adjacent / Bay Area relevant, or universal? This drives Phase 4. + +**Output of Phase 2:** A `source_brief` markdown block with all 7 fields filled in. + +### Phase 3 — Decide the Repurpose Angle + +**Read:** `references/03-angle.md` for the angle-selection logic. + +Don't just rewrite the same video. Decide HOW Graeham's version is going to be different and better. Pick ONE of these angles (or hybrid two): + +1. **Same claim, better evidence** — Original makes a point, Graeham backs it with real data, dates, and citations. +2. **Contrarian take** — Original says X, Graeham respectfully argues why X is incomplete or wrong with his market expertise. +3. **Local lens** — Original is a universal real estate take, Graeham reframes it for Bay Area / Peninsula specifically. +4. **Deeper expert breakdown** — Original is surface-level, Graeham goes one layer deeper as a working agent. +5. **Personal story wrapper** — Original is informational, Graeham frames it around a real client situation he's seen. + +**Output of Phase 3:** A single sentence — `Repurpose Angle: `. + +### Phase 4 — Research & Data Injection (the part the Content Engine has that SurfFast doesn't) + +**Read:** `references/04-research.md` for the research playbook. + +This is the phase that fixes the problem you flagged. SurfFast's default output gives you the words but none of the substance. This phase injects the data layer. + +**If real-estate / Bay Area adjacent:** + +- Pull market stats from `video-script-creation-engine/references/market-config.md` if relevant (price ranges, days on market, etc. — date-stamp everything) +- Reference AB 1482 / California-specific rules where applicable, with current year date anchor +- If a specific neighborhood is named, pull the right talking points (zoning, housing stock, commute, walkability — never demographics, see Fair Housing block below) +- If the topic appears in `video-script-creation-engine/references/topic-history.json`, link to or note Graeham's prior coverage + +**If universal (non-real-estate):** + +- Run web search for current data, statistics, expert sources relevant to the claim +- Verify any numerical claims from the source transcript before repeating them — flag if they're stale or wrong +- Cite sources inline so the script reads as authoritative + +**Output of Phase 4:** A `research_pack` markdown block with 3-7 cite-ready facts, each with source and date stamp. + +### Phase 5 — Hook Generation (3 Variants + Score) + +**Read:** `references/hook-frameworks.md` for the 8 patterns and scoring rubric. + +Generate THREE hook variants drawn from different frameworks. Score each on the 4-criteria rubric (Pattern Interrupt, Curiosity Gap, Specificity, Graeham Voice Fit). Recommend the winning hook with a 1-line rationale. + +The hook is the single highest-leverage improvement we can make over the source video. Spend real effort here — don't just rephrase the original opener. + +**Output of Phase 5:** Three labeled hook variants with scores and a recommendation. + +### Phase 6 — Script Writing (Multi-Platform Derivatives) + +**Read:** `references/06-script-writing.md` for the script structure templates and the platform spec matrix. + +Produce the full content package with these derivatives: + +| Format | Specs | What to include | +|---|---|---| +| **YouTube Long** | 8-15 min, 16:9 | Fullest version with inline shot direction tags `[TALKING HEAD]`, `[B-ROLL]`, `[TEXT OVERLAY]`, full editing notes block | +| **YouTube Short** | 30-59 sec, 9:16 | Tightest version — hook, one insight, CTA | +| **IG Reel** | 30-60 sec, 9:16 | Hook-first, face-to-camera friendly, caption-overlay friendly | +| **TikTok** | 30-60 sec, 9:16 | More casual cadence, trending-audio-compatible if relevant | +| **Caption + hashtags** | Per-platform | One caption set tuned to each platform | +| **GHL keyword CTA** | One per script | Use the active keyword set listed below | + +**Inline shot direction tags** — embed these directly in the script text the same way the Content Engine does. Don't separate them into their own section. Format examples in `references/06-script-writing.md`. + +**Voice & style** — Match Graeham's voice and style guide. Reference `video-script-creation-engine/references/phases/script-writer/references/voice-and-style.md` if available — same voice rules apply. + +**Active GHL keywords** — `SELL`, `BUY`, `COSTS`, `OPTIONS`, `1482`, `EPA`, `VALUE`, `READY`, `INVEST`, `NUMBERS`, `RELOCATING`, `MARKET`, `CHECKLIST`, `WATCH`, `RWC`, `PA`, `MP`, `SF`. Format CTA as: "Comment [KEYWORD] below and I'll send you [lead magnet]." + +**Output of Phase 6:** Full content package markdown block. + +### Phase 7 — Production Handoff (HeyGen + Higgsfield + ElevenLabs) + +**Read:** `references/07-handoff.md` for handoff format details. + +Produce three production-ready handoff blocks: + +**A. HeyGen Avatar Script Block** + +Format the script ready to paste into HeyGen. Recommend an avatar look from Graeham's set (see `heygen-video/references/avatars.md`) based on the topic and tone. Note: HeyGen skill requires Graeham to confirm the avatar — list 2-3 recommendations with rationale, don't pick silently. + +``` +HEYGEN-READY SCRIPT +Recommended look: (rationale: ) +Backup look: (rationale: ) +Aspect: 9:16 or 16:9 (portrait looks = 9:16, landscape looks = 16:9) +Script: + +``` + +**B. Higgsfield B-Roll Prompt Pack** + +For every `[B-ROLL: ...]` tag in the script, generate a paired Nano Banana Pro image prompt + Seedance/Kling motion prompt. See `higgsfield-video/SKILL.md` for the Nano Banana realism anchor stack (Gray Malin + Douglas Friedman + Kodak Portra 400 for real estate). + +``` +B-ROLL #1 +Image prompt (Nano Banana Pro): +Motion prompt (Seedance 2.0): +Duration: 5s | 10s | 15s +Aspect: 9:16 or 16:9 +Use in edit: +``` + +**C. ElevenLabs SSML Block** + +Wrap the script in `` tags with `` and `` markup so Graeham can paste directly into ElevenLabs. Same format as the Content Engine — see `video-script-creation-engine/references/phases/script-writer/references/elevenlabs-audio-tags.md` if it's available, otherwise apply the standard pattern: hook = faster/higher pitch, education = medium, CTA = slower/louder. + +**Output of Phase 7:** All three handoff blocks appended to the content package. + +### Phase 8 — Humanizer Pass (Auto, Required) + +**Read:** `humanizer/SKILL.md` and `references/08-humanizer.md`. + +This is the non-negotiable final step. Run the entire generated content package — every hook variant, the chosen script across all platform derivatives, captions, ElevenLabs SSML — through the humanizer's pattern list. The humanizer skill catches the AI-isms that quietly tank engagement: significance inflation, promotional language, AI vocabulary, em dash overuse, rule-of-three, sycophantic openings, generic conclusions. + +**How to invoke:** After Phase 7 completes, invoke the `humanizer` skill on the final content package. Provide Graeham's voice-and-style reference (from the Content Engine) as the voice-matching sample so humanizer rewrites toward Graeham's actual cadence, not a generic "natural" voice. + +Apply humanizer to: +- Hook variants (yes, before scoring — so we're scoring humanized versions) +- All script bodies (YouTube Long, Short, Reel, TikTok) +- Captions +- Editing notes prose (NOT the bracketed shot tags — those are intentionally structural) + +Skip humanizer on: +- ElevenLabs SSML XML (it's machine-readable markup) +- HeyGen-ready script block (already the humanized script, no need to double-pass) +- Higgsfield image/motion prompts (those are technical generation prompts, not human-facing copy) + +**Output of Phase 8:** A final, humanized content package as a single markdown blob, ready for Phase 9 to split. + +### Phase 9 — Delivery (split into separable files + Property-OS-styled HTML preview) + +**Read:** `references/09-delivery.md`. + +The humanized package from Phase 8 is one big markdown file. Editors don't want one big file — Jason wants the editing notes, Ellie wants the captions, Graeham wants the HeyGen-ready script. Phase 9 splits the package into a folder of separable artifacts plus a self-contained `index.html` preview in the Property OS visual style. + +**Invocation:** + +```bash +python3 /sessions/*/mnt/Skills/skills/transcript-repurposer/scripts/deliver.py \ + --package \ + --transcript Repurposed Peninsula mortgage rate video. Hook upgrade: vague "rates are crazy" → "$73,000/year waiting cost." Localized to San Mateo County with April 2026 MLS data. CTA: OPTIONS keyword. diff --git a/skills/transcript-repurposer/references/00-auto-transcribe.md b/skills/transcript-repurposer/references/00-auto-transcribe.md new file mode 100755 index 0000000..c73e66d Binary files /dev/null and b/skills/transcript-repurposer/references/00-auto-transcribe.md differ diff --git a/skills/transcript-repurposer/references/01-ingest.md b/skills/transcript-repurposer/references/01-ingest.md new file mode 100755 index 0000000..b379d2b --- /dev/null +++ b/skills/transcript-repurposer/references/01-ingest.md @@ -0,0 +1,88 @@ +# Phase 1 — Ingest the Transcript + +The job of this phase is simple: normalize whatever messy thing the user hands over into a clean `source_text` string plus a small `source_metadata` block. Don't write or analyze yet — just normalize. + +## Accepted Input Formats + +### 1. Pasted text (most common with SurfFast) + +User pastes the transcript directly in chat. Strip: +- Quote markers (`>`, `"`, `'`, smart-quote variants) +- Leading/trailing whitespace per line +- Speaker labels if any (`Speaker 1:`, `Host:`, `[NARRATOR]`) +- Timestamps embedded inline (`[0:42]`, `(1:15)`, `00:00:23,500`) +- Repeated filler that obviously came from auto-transcription glitches (consecutive duplicate words, `[Music]`, `[Applause]`, `[Laughter]`) + +Preserve: +- Sentence punctuation +- Paragraph breaks (signal of natural pauses) +- Proper nouns capitalized (don't lower-case everything) + +### 2. `.txt` file + +Read with the Read tool. Treat content same as pasted text — run the same strip rules. + +### 3. `.srt` subtitle file + +Structure looks like: + +``` +1 +00:00:00,000 --> 00:00:03,500 +Welcome back to the channel. + +2 +00:00:03,500 --> 00:00:07,200 +Today we're talking about mortgage rates. +``` + +Strip sequence numbers, timing cues, and re-flow into continuous prose. Preserve paragraph breaks where there's a >2 second gap (signal of section breaks). + +### 4. `.vtt` subtitle file + +Similar to SRT but with `WEBVTT` header and slightly different timing format (`00:00:00.000 --> 00:00:03.500`). Same stripping rules. + +### 5. SurfFast JSON + +If SurfFast exports JSON, look for a `text`, `transcript`, or `subtitles` field. Extract it. Pull source metadata from: +- `source_url` → tells you the platform (instagram.com → IG, youtube.com → YT, tiktok.com → TT) +- `title` or `description` → useful as a fallback topic clue +- `creator` or `channel` → note for context but don't include in Graeham's output +- `duration` → calculate spoken pace from word count + +### 6. Audio file (`.mp3`, `.wav`, `.m4a`, `.mp4` with audio) + +**Stop. We don't transcribe in this skill.** Tell the user one of: + +> "I can rebuild this once we have the transcript. You have two options: (1) run the audio through SurfFast's own subtitle download, or (2) use the Content Engine's Whisper transcriber (`video-script-creation-engine/scripts/youtube_transcriber.py`) — it's free and local. Paste the transcript back here when ready." + +This isn't dodging — transcription is a separate concern with its own tooling, and the Content Engine already has it solved. + +## Output of Phase 1 + +Produce two artifacts and pass them to Phase 2: + +### `source_text` + +The clean continuous-prose version, no timing, no speaker labels, no transcription artifacts. This is the only thing Phase 2 will read for analysis. + +### `source_metadata` + +A single paragraph: + +``` +Source: +Approx length: words / ~ when spoken +Creator handle (for reference only, will not appear in Graeham's output): +Anomalies flagged: +``` + +The creator handle is captured ONLY so Phase 4 can verify any factual claims the creator made (e.g., if they say "I'm a CFP" we know to verify the data they cite). It will never appear in Graeham's repurposed output. We are not crediting or referencing the original creator — repurposing is rewriting in Graeham's voice with his own framing and evidence. + +## Edge Cases + +- **Transcript is in another language.** Ask the user if they want the rebuild in English or in the source language. Default to English unless told otherwise. +- **Transcript is very short (< 50 words).** Probably not enough source signal to repurpose. Tell the user — it's better to use the Content Engine to write from scratch than to stretch a thin transcript. +- **Transcript is very long (> 4000 words, i.e. long podcast).** Ask the user: do they want one long-form repurpose covering the full thing, or should we extract the 2-3 strongest segments and repurpose each separately? Recommend the latter for podcasts — it produces tighter content. +- **Transcript has obvious factual errors.** Flag them in the `source_metadata` so Phase 4 knows not to carry them forward. +- **Transcript contains demographic/Fair-Housing-violating language.** Flag those exact lines. Phase 6 will strip them with explanation. diff --git a/skills/transcript-repurposer/references/02-analyze.md b/skills/transcript-repurposer/references/02-analyze.md new file mode 100755 index 0000000..915101d --- /dev/null +++ b/skills/transcript-repurposer/references/02-analyze.md @@ -0,0 +1,112 @@ +# Phase 2 — Analyze the Source + +Goal of this phase: produce a one-page `source_brief` that tells the writer in Phase 6 exactly what was in the original video, so the rewrite is informed rather than guesswork. + +You're not editing or improving yet. You're diagnosing. + +## The 7-Field Source Brief + +Fill in each field. Be specific. Vague analysis here cascades into vague output later. + +### 1. Core Claim + +One sentence. What is the original video actually arguing or telling the viewer? + +**Good:** "Mortgage rates in 2026 are going to stay above 6.5% because the Fed has signaled no cuts before Q4." + +**Bad:** "It's about mortgage rates and the economy." (Too vague — doesn't capture the position.) + +If the source has multiple claims, identify the **primary** one (the one the hook + payoff are built around). Note the secondary claims separately for reference. + +### 2. Hook Strategy + +Identify which of the 8 hook patterns the original creator used. See `hook-frameworks.md`. Common ones in social video: + +- **Bold contrarian claim** ("Everyone says X. They're wrong.") +- **Personal stake** ("I just lost $50K because of this mistake.") +- **Pattern interrupt question** ("Why is your landlord suddenly being nice to you?") +- **Specific number shock** ("87% of buyers do this wrong.") +- **Story tease** ("Last week, a client almost lost her deposit because...") +- **Direct address** ("If you're renting in California, watch this.") +- **Curiosity gap** ("There's one thing nobody tells you about closing costs.") +- **Reveal frame** ("I'll tell you what every agent secretly knows.") + +If the hook strategy was weak (e.g., the creator opened with "Hey guys, welcome back to the channel"), note that — it's an upgrade opportunity for Phase 5. + +### 3. Evidence Quality + +For each factual or numeric claim in the source, tag it: + +- **Cite-able** — Specific number, date, source — could be defended in writing. +- **Anecdotal** — "I had a client who..." — personal experience, not generalizable. +- **Vague** — "Most people..." / "Studies show..." with no source — needs verification or removal. +- **Opinion** — Framed as opinion, no evidence needed. + +Flag anything that needs fact-checking in Phase 4. Don't carry forward unverified vague claims — they'll get cut and replaced with real data. + +### 4. Structure + +Identify the macro structure: + +- **Hook → Setup → Payoff → CTA** (most common 30-90 sec video) +- **List format** ("3 things every buyer does wrong: number one...") +- **Story format** ("Last week I met a client who...") +- **Comparison** ("Here's what changed from 2024 to 2026.") +- **Problem → Solution** ("If you're stuck on this... here's the move.") +- **Q&A / Reaction** (creator responds to a viewer question or another video) + +The chosen repurpose angle in Phase 3 may keep this structure or deliberately change it. + +### 5. Target Audience Signal + +Who was the original creator talking to? Listen for second-person address, jargon level, problems referenced, life-stage clues: + +- **First-time buyers** — references to "your first home", down payment, FHA loans +- **Move-up buyers** — references to "selling and buying at the same time", contingencies +- **Investors** — references to cap rates, rentals, cash flow, 1031 +- **Renters** — references to leases, rent increases, security deposits +- **Sellers** — references to listing, staging, days on market, agent commission +- **General real estate curious** — broad consumer audience, no jargon +- **Other industry pros** — agent-to-agent talk, MLS, escrow speak + +This matters because Graeham's CTA + GHL keyword will be chosen to match the audience. A first-time-buyer video uses `BUY` or `READY`; an investor video uses `INVEST` or `NUMBERS`. + +### 6. Length and Pace + +- **Word count** of the source +- **Estimated spoken duration** (assume ~150 wpm for conversational pace; ~180 wpm for fast social-media pace) +- **Ideas per 30 seconds** — count the number of distinct claims or transitions. High density (5+) = fast-paced reel. Low density (1-2) = long-form video. + +This calibrates Phase 6 — Graeham's rewrite should match the energy of the platform he's posting to, not slavishly copy the source's pacing. + +### 7. Localization Need + +Critical for Phase 4. Tag the topic on this 3-bucket scale: + +- **Strongly local** — Topic is Bay Area / Peninsula / California specific by nature (AB 1482, Prop 19, EPA rent control, SF luxury market, Peninsula commute). Phase 4 pulls heavy local data. +- **Real estate universal** — Topic applies to real estate broadly (mortgage rates, closing costs, buyer-seller psychology) but Graeham CAN frame it locally to deepen authority. Phase 4 adds Bay Area lens where it lands naturally; doesn't force it. +- **Universal non-real-estate** — Topic is general life / business / lifestyle (productivity, mindset, finance basics). Phase 4 does universal research; localization is optional and only if it strengthens the angle. + +When in doubt, default to the middle bucket — real estate universal — because Graeham's authority advantage shows up when he adds the Bay Area lens to something the original creator left generic. + +## Output Format + +Produce a `source_brief` block in markdown like: + +``` +## Source Brief + +1. Core Claim: +2. Hook Strategy Used: +3. Evidence Quality: + - Claim A: + - Claim B: ... +4. Structure: +5. Target Audience: +6. Length and Pace: words / ~s / +7. Localization Need: + +Anomalies / Fair Housing flags: +``` + +Hand this brief directly to Phase 3. diff --git a/skills/transcript-repurposer/references/03-angle.md b/skills/transcript-repurposer/references/03-angle.md new file mode 100755 index 0000000..21f43cb --- /dev/null +++ b/skills/transcript-repurposer/references/03-angle.md @@ -0,0 +1,87 @@ +# Phase 3 — Decide the Repurpose Angle + +The whole reason this skill exists is because Graeham's repurposed videos were coming out sounding like watered-down versions of the source. That happens when the angle isn't deliberately chosen — the script just rephrases the original. + +This phase forces a deliberate choice. Pick ONE primary angle (optionally hybrid with a second). The angle drives every downstream decision in Phases 5-7. + +## The 5 Angles + +### Angle 1 — Same Claim, Better Evidence + +**When to use:** The source's core claim is correct and useful, but the evidence is thin (anecdotes, vague stats, no sources). Graeham keeps the position and backs it with real data and dates. + +**Example:** Source says "rates are staying high in 2026." Graeham keeps that claim but cites the actual Fed signal, the CPI print, and the impact on Bay Area buying power with specific monthly payment math. + +**Risk:** If overdone, this turns into a fact-dump and loses the social-video energy. Keep the human read-out of the data, not just the data itself. + +### Angle 2 — Contrarian Take + +**When to use:** Source's core claim is wrong, oversimplified, or missing context that an actual working agent would know. Graeham respectfully pushes back. + +**Example:** Source says "always offer 10K over asking in this market." Graeham counters: "That's a 2022 playbook — here's what actually wins in 2026 Peninsula, and it's not blind overbids." + +**Risk:** Don't shadow-box the source creator. Don't reference them by name or with phrases like "I saw a video saying..." — that gives free attention to a competitor and looks petty. Frame the contrarian position as "the common advice" or "the conventional wisdom" without naming a target. + +### Angle 3 — Local Lens + +**When to use:** Source is a universal real estate take that ignores geography. Graeham reframes specifically for Bay Area / Peninsula audiences. + +**Example:** Source talks generically about "buying in a high-rate environment." Graeham reframes: "Here's how Peninsula buyers are actually winning in this rate environment — the three plays that worked in March 2026." + +**Risk:** Make sure the local lens is the strongest version of the angle, not a token mention. If the entire video could be filmed in any city, you haven't really localized. + +### Angle 4 — Deeper Expert Breakdown + +**When to use:** Source is surface-level. The "what" is right but the "why" or "how" is missing. Graeham goes one level deeper as a working agent. + +**Example:** Source says "always ask for repair credits." Graeham goes deeper: "Here's the actual mechanics of asking for repair credits — when to ask in cash vs in price reduction, what inspectors should be present, and the three line items sellers always say yes to and the two they always fight." + +**Risk:** Don't go SO deep that the social-video format breaks. If it needs to be a 12-min YouTube long-form, that's fine — but don't try to compress 12 min of depth into a 45-second reel. Match depth to the target derivative. + +### Angle 5 — Personal Story Wrapper + +**When to use:** Source is informational. Graeham frames the same insight around a real client situation (anonymized) that he's actually navigated. + +**Example:** Source explains "contingencies in offers." Graeham opens with: "Last week I had a buyer almost lose her dream home over the inspection contingency. Here's what happened, and what I'd tell her to do differently." Then teaches the lesson through the story. + +**Risk:** The client story has to be real (or composite-but-honest). Don't fabricate. And anonymize — never share identifying details. Graeham's brand is built on actual market experience, not invented scenarios. + +## Hybrid Angles + +You can combine angles when they reinforce. Common useful pairs: + +- **Local Lens + Personal Story** — Use a Peninsula client's experience to deliver the local frame. Strongest version of either angle. +- **Same Claim + Deeper Breakdown** — Affirm the source's takeaway, then go deeper than they did. +- **Contrarian + Personal Story** — Push back on conventional wisdom by showing what actually worked in a recent transaction. + +Avoid combining Contrarian + Same Claim (contradictory) or piling more than 2 angles together (loses focus). + +## Angle Selection Heuristic + +Given the source brief from Phase 2, here's how to pick: + +| Source brief signal | Angle to pick | +|---|---| +| Core claim is correct, evidence is anecdotal/vague | Angle 1 — Same Claim, Better Evidence | +| Core claim is questionable or oversimplified | Angle 2 — Contrarian Take | +| Source is real-estate-universal, no geography | Angle 3 — Local Lens | +| Source is surface-level on a topic Graeham works in daily | Angle 4 — Deeper Expert Breakdown | +| Source is purely informational, no human element | Angle 5 — Personal Story Wrapper | + +If two angles fit, lean toward the one that produces the strongest hook. A contrarian hook outperforms a "better evidence" hook on social, so when in doubt and the source has any weakness, lean contrarian. + +## Output + +A single sentence: + +``` +Repurpose Angle: +``` + +Optional second sentence if hybrid: + +``` +Secondary Angle: +``` + +Pass this directly to Phase 4 (research is angle-dependent — contrarian needs counter-evidence, local lens needs local data, personal story needs a real client situation to anchor on). diff --git a/skills/transcript-repurposer/references/04-research.md b/skills/transcript-repurposer/references/04-research.md new file mode 100755 index 0000000..56b089b --- /dev/null +++ b/skills/transcript-repurposer/references/04-research.md @@ -0,0 +1,102 @@ +# Phase 4 — Research & Data Injection + +This is the phase that fixes the actual problem the user flagged. SurfFast gives us the words. SurfFast does NOT give us the data layer that makes Graeham's Content Engine scripts feel authoritative instead of like a karaoke version of someone else's video. + +The output of this phase is a `research_pack` — a small set of cite-ready, date-stamped facts that Phase 6 will weave into the script. + +## Branching Logic — Where to Look Based on Localization Need + +From Phase 2's source brief, you tagged the topic as one of: strongly local, real estate universal, or universal non-real-estate. Each branch uses a different research playbook. + +## Branch A — Strongly Local (Bay Area / Peninsula / California) + +The Content Engine already has a rich set of references for Graeham's markets. Reuse them — don't reinvent. + +**Step 1:** Read `video-script-creation-engine/references/market-config.md` for: +- Primary market: East Palo Alto +- Secondary markets: Redwood City, Palo Alto, Menlo Park, San Mateo County, Peninsula +- Lead magnets and CTA matrix +- Current jurisdiction-specific process terms + +**Step 2:** If the topic involves a specific legal/regulatory item, reach for: +- AB 1482 — California Tenant Protection Act (5% + CPI or 10% cap) +- Prop 19 — Property tax base transfer rules +- TDS / SPQ / AVID disclosure obligations +- East Palo Alto Rent Stabilization Ordinance (more restrictive than AB 1482 for covered units) +- San Mateo County transfer tax structure + +**Step 3:** Pull market data with explicit date anchors. NEVER quote a bare statistic. Always frame as "As of , ." If we don't have a fresh stat for the month, web-search for the most recent and date-stamp accurately. + +**Step 4:** Check `video-script-creation-engine/references/topic-history.json` — if Graeham has covered this topic before, reference his prior coverage in a "see also" line so the content cross-links. Don't repeat exactly what he said last time. + +## Branch B — Real Estate Universal + +Topic applies broadly (mortgage rates, closing costs, buyer psychology, market cycles) but Graeham CAN deepen authority by adding the Bay Area lens where it lands naturally. + +**Step 1:** Web search for the most current national/state-level data on the topic. Verify any numerical claims from the source transcript — flag if the source was using a stale 2024 or 2025 number. + +**Step 2:** Identify ONE specific Bay Area localization that strengthens the point. For example, if the topic is "buying in a high-rate environment," the local angle could be Peninsula-specific monthly payment math at the current rate vs. 18 months ago. One strong local data point beats five generic mentions. + +**Step 3:** Pull current source citations — Fed funds rate, CPI print, NAR median sale price, Case-Shiller, whatever is relevant. Date-stamp each. + +**Step 4:** Don't force local references where they don't naturally fit. If the topic is genuinely universal, a forced "in the Bay Area..." line will sound tacked-on. + +## Branch C — Universal Non-Real-Estate + +Topic is general life / business / lifestyle / finance basics. Real estate connection is optional. + +**Step 1:** Web search for current data, statistics, expert sources relevant to the topic. + +**Step 2:** Verify any numerical claims from the source transcript. Don't carry stale data forward. + +**Step 3:** If the topic CAN bridge to real estate naturally (e.g., personal finance topic → connection to homebuying), build one bridge line for the CTA. If not, leave the CTA generic ("DM me if you want to talk Bay Area real estate"). + +**Step 4:** Don't over-localize. Forced real estate connections from unrelated topics will feel salesy and tank engagement. + +## Fact-Verification Rules + +For every numeric or factual claim that's going into Graeham's script: + +1. **Has a source.** Web URL, document name, or "Graeham's direct experience" (for personal stories). +2. **Has a date.** Either the publication date of the source, or the as-of date for the data point. +3. **Is current to within 6 months** for market data, 12 months for legal/regulatory data, indefinite for historical facts. +4. **Is defensible if challenged in a comment.** If a viewer pushes back on the claim, Graeham should be able to point to where it came from. + +If a claim from the source transcript fails any of these tests, replace it or remove it. Don't repeat it just because the source did. + +## Output Format + +``` +## Research Pack — As of + +Verified Facts (cite-ready): +1. . Source: . Date: . +2. . Source: . Date: . +3. . Source: . Date: . +... + +Source-Transcript Claims Filtered Out: +- "" — Reason: +- ... + +Bay Area Lens Insertion Points (if applicable): +- Phase 6 should land this stat: "" at +- ... + +Lead Magnet / CTA Recommendation: +- GHL Keyword: +- Lead Magnet: +- Rationale: +``` + +Hand this directly to Phase 5 (hook generation — strong hooks pull from research data) and Phase 6 (script writing). + +## Time Budget + +Phase 4 should take real effort but not blow the context. For a typical 30-90 second source transcript, target: + +- 3-7 verified facts in the research pack +- 1-2 web searches if needed +- 5 minutes of attention + +If you find yourself going down a 30-min research rabbit hole, you're over-budget for a social repurpose. Compress and move on. diff --git a/skills/transcript-repurposer/references/06-script-writing.md b/skills/transcript-repurposer/references/06-script-writing.md new file mode 100755 index 0000000..4bedb24 --- /dev/null +++ b/skills/transcript-repurposer/references/06-script-writing.md @@ -0,0 +1,220 @@ +# Phase 6 — Multi-Platform Script Writing + +By the time you get here you have: the source brief, the chosen repurpose angle, the research pack, and the winning hook. This phase produces the actual content package. + +## The Platform Spec Matrix + +Each derivative has its own format. Don't write one script and try to make it fit everywhere — write to the format. + +| Format | Duration | Aspect | Word count (approx) | Hook depth | Body structure | +|---|---|---|---|---|---| +| **YouTube Long** | 8-15 min | 16:9 | 1200-2200 | First 30 sec | Hook → context → 3-5 key points → tactical detail → CTA | +| **YouTube Short** | 30-59 sec | 9:16 | 80-150 | First 3 sec | Hook → ONE insight → CTA | +| **IG Reel** | 30-60 sec | 9:16 | 80-150 | First 2 sec | Hook → ONE insight → CTA (caption-overlay compatible) | +| **TikTok** | 30-60 sec | 9:16 | 80-150 | First 2 sec | Hook → ONE insight → CTA (slightly more casual cadence) | +| **IG Carousel** | 5-10 slides | 1:1 or 4:5 | 30-60 per slide | Slide 1 | Slide 1 = hook image+text; slides 2-N = key facts; final slide = CTA | +| **Blog** | n/a | n/a | 800-1200 | First paragraph | SEO H1 → intro → H2 sections matching key points → cite-ready conclusion | +| **GMB post** | n/a | n/a | 100-300 | First sentence | Local SEO-tuned, one CTA, one image | +| **Facebook caption** | Cross-post adaptable | varies | 100-500 | First line | Longer caption OK; link to YT or website | + +The user can request one derivative or all. Default behavior: produce all of them in one content package unless the user asks for fewer. + +## Voice and Style — Graeham's Calibration + +Match the voice rules from `video-script-creation-engine/references/phases/script-writer/references/voice-and-style.md`. If that file isn't accessible in the current session, fall back to these baselines: + +- **Direct.** No throat-clearing. State the point. +- **Confident but calibrated.** Strong opinions, room for nuance. Don't overclaim. +- **Specific.** Concrete numbers, places, dates. "Redwood City April 2026" beats "in the Bay Area recently." +- **Plain English over jargon.** When jargon is needed, define it once. +- **Working-agent voice.** Graeham sees deals every week. Write like someone who actually does this for a living, not someone who read about it. +- **No hype-isms.** No "absolutely insane", "game-changing", "you won't believe", "literally". Cut on sight. +- **No corporate-ese.** No "leverage", "synergy", "ecosystem", "at its core". Cut on sight. +- **Humor lands when it's dry.** Understated humor is on-brand. Hype humor is off-brand. + +## Inline Shot Direction Tags + +Embed these directly in YouTube Long and (sparingly) in YouTube Short script text. Don't break them out into a separate section — they need to be inline so Jason (the editor) sees them in context. + +``` +[TALKING HEAD] — Graeham speaking direct to camera +[B-ROLL: ] — Overlay footage +[TEXT OVERLAY: ""] — On-screen text/graphics +[DRONE: ] — Drone footage +[SCREEN RECORD: ] — Screen capture +[TRANSITION: ] — Cut / dissolve / swipe / jump +``` + +For IG Reel and TikTok scripts, use lighter tags — they're more often single-shot face-to-camera with caption overlay, so tag sparingly: + +``` +[CAPTION OVERLAY: ""] — Burned-in caption +[CUT] — Hard cut between takes (jump cut) +``` + +## Editing Notes Block (YouTube Long only) + +For the YouTube Long derivative, append a structured editing notes block aimed at Jason or whoever's editing: + +``` +EDITING NOTES FOR JASON + +B-ROLL SHOT LIST: +- +- +- + +TEXT OVERLAY TIMING: +- [00:08] → "Title overlay text" (duration: 3s) +- [00:42] → "Stat callout text" (duration: 4s) +- ... + +PACING NOTES: +- Hook: fast cuts every 1.5-2 sec +- Body: medium pace, 3-5 sec per cut +- CTA: slower, hold the camera 2-3 sec longer + +THUMBNAIL CONCEPT: +- Text: "" +- Expression: +- Background: +- Color palette: + +MUSIC / SFX: +- Music mood: +- Specific SFX moments: +``` + +## ElevenLabs SSML Block + +Required for YouTube Long. Optional for short-form (still useful if Graeham is doing voice-over). + +Wrap the script in `` tags. Use `` for emphasis shifts and `` for natural pauses. + +```xml + + + The opening line of the hook, + + + + the high-stakes claim or number. + + + + Continuing the body... + + +``` + +Prosody calibration: +- **Hook** — slightly faster rate, slightly higher pitch (energy) +- **Body/educational** — medium rate, medium pitch (clarity) +- **Key stat or surprising line** — slower rate, lower pitch, louder volume (emphasis) +- **CTA** — slower rate, deliberate, louder (call to action energy) + +## Captions Per Platform + +| Platform | Style | Length | Hashtag count | +|---|---|---|---| +| Instagram | Punchy first line (hooks in feed), then body, then CTA | 100-200 words | 5-10 | +| TikTok | Very short, hook-heavy, often single sentence | 30-80 words | 3-6 | +| YouTube Short | Title-style + brief description + CTA | 50-100 words | 3-5 | +| YouTube Long description | SEO-tuned, full breakdown, links, chapters | 200-400 words | 0-5 | +| Facebook | Conversational tone, more room | 100-300 words | 2-5 | +| GMB | Local-keyword-heavy, location tagged | 100-300 words | 0 | + +## CTA Slot — GHL Keyword Capture + +Every script ends with a comment-keyword CTA. Pull from the active keyword set: + +| Keyword | Lead magnet (typical) | Use when | +|---|---|---| +| `SELL` | Seller toolkit | Topic targets sellers | +| `BUY` | Buyer toolkit | Topic targets buyers (general) | +| `COSTS` | Closing cost breakdown PDF | Cost / fee topics | +| `OPTIONS` | Loan options comparison | Mortgage / financing topics | +| `1482` | AB 1482 tenant rights guide | California rent / tenant topics | +| `EPA` | East Palo Alto market report | EPA-specific | +| `VALUE` | Free home valuation | Selling / equity topics | +| `READY` | First-time-buyer checklist | First-time-buyer topics | +| `INVEST` | Investment property analysis | Investor topics | +| `NUMBERS` | Cap rate / cash flow calculator | Investor finance topics | +| `RELOCATING` | Relocation guide | Move-in / out-of-area topics | +| `MARKET` | Latest market update | General market topics | +| `CHECKLIST` | Pre-listing prep checklist | Prep / staging topics | +| `WATCH` | Off-market opportunities watchlist | Inventory / availability topics | +| `RWC` | Redwood City market report | RWC-specific | +| `PA` | Palo Alto market report | PA-specific | +| `MP` | Menlo Park market report | MP-specific | +| `SF` | San Francisco market report | SF-specific | + +Format: "Comment below and I'll send you ." + +## Cross-Posting Consistency + +When generating all derivatives at once, ensure: + +1. **Core claim is consistent.** The same point is being made across all formats. Don't soften it on one platform and strengthen it on another — that produces brand inconsistency. +2. **Hook is variant per platform.** The 60-second IG Reel hook is sharper/faster than the 12-minute YouTube long hook. Adapt, don't duplicate verbatim. +3. **CTA keyword is consistent.** Same GHL keyword across all derivatives so the lead capture lands in one bucket. +4. **Data points are identical.** If the YouTube long cites "82% of Peninsula buyers in April 2026 paid over asking," the IG Reel cites the same stat with the same date stamp. No "approximately" on one platform and exact on another. + +## Output Format + +``` +## Content Package — + +### Repurpose Brief +- Source: +- Repurpose Angle: +- Recommended Hook: Variant from hook generation + +### YouTube Long (8-15 min, 16:9) + + +EDITING NOTES FOR JASON: + + +ELEVENLABS SSML: + + +### YouTube Short (30-59 sec, 9:16) + +Caption: +Hashtags: + +### Instagram Reel (30-60 sec, 9:16) + + +""" + return html + + +def main(): + parser = argparse.ArgumentParser(description="Split content package into artifact files + render HTML index") + parser.add_argument("--package", required=True, help="Path to the humanized content package markdown") + parser.add_argument("--transcript", required=True, help="Path to the source transcript text") + parser.add_argument("--slug", required=True, help="Short slug for the output folder") + parser.add_argument("--output-dir", required=True, help="Where to write the artifact folder") + parser.add_argument("--source-url", default="", help="Original video URL") + parser.add_argument("--platform", default="", help="Source platform (youtube|instagram|tiktok|...)") + parser.add_argument("--title", default="", help="Original video title") + parser.add_argument("--duration-sec", type=int, default=0, help="Source video duration in seconds") + parser.add_argument("--word-count", type=int, default=0, help="Transcript word count") + parser.add_argument("--tier", default="whisper", help="Transcription tier used") + args = parser.parse_args() + + pkg_path = Path(args.package) + trans_path = Path(args.transcript) + out_dir = Path(args.output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + package_md = pkg_path.read_text(encoding="utf-8") + transcript_text = trans_path.read_text(encoding="utf-8") if trans_path.exists() else "" + + sections = split_sections(package_md) + + # Write each artifact + artifacts = [] + + # Source transcript + (out_dir / "transcript.txt").write_text(transcript_text, encoding="utf-8") + artifacts.append({ + "filename": "transcript.txt", "name": "Source transcript", "icon": "📝", + "description": "Raw transcribed text from the original video", + }) + + # Full content package (canonical) + (out_dir / "content-package.md").write_text(package_md, encoding="utf-8") + artifacts.append({ + "filename": "content-package.md", "name": "Full content package", "icon": "📦", + "description": "Everything in one markdown — all derivatives + handoff blocks", + }) + + # Hooks (3 variants) + hooks = find_section(sections, "hook") + if hooks: + (out_dir / "hooks.md").write_text(hooks, encoding="utf-8") + artifacts.append({ + "filename": "hooks.md", "name": "Hook variants", "icon": "🎣", + "description": "Three scored hook variants with the recommendation", + }) + + # YouTube Long + yt_long = find_section(sections, "youtube", "long") + if yt_long: + (out_dir / "script-yt-long.md").write_text(yt_long, encoding="utf-8") + artifacts.append({ + "filename": "script-yt-long.md", "name": "YouTube Long", "icon": "🎬", + "description": "8-15 min long-form script with shot directions", + }) + + # YouTube Short + yt_short = find_section(sections, "youtube", "short") + if yt_short: + (out_dir / "script-yt-short.md").write_text(yt_short, encoding="utf-8") + artifacts.append({ + "filename": "script-yt-short.md", "name": "YouTube Short", "icon": "📱", + "description": "30-59 sec vertical script for YT Shorts", + }) + + # IG Reel + ig_reel = find_section(sections, "instagram", "reel") or find_section(sections, "reel") + if ig_reel: + (out_dir / "script-ig-reel.md").write_text(ig_reel, encoding="utf-8") + artifacts.append({ + "filename": "script-ig-reel.md", "name": "Instagram Reel", "icon": "📸", + "description": "30-60 sec Reel script with caption overlay tags", + }) + + # TikTok + tiktok = find_section(sections, "tiktok") + if tiktok: + (out_dir / "script-tiktok.md").write_text(tiktok, encoding="utf-8") + artifacts.append({ + "filename": "script-tiktok.md", "name": "TikTok", "icon": "🎵", + "description": "30-60 sec TikTok script", + }) + + # Blog + blog = find_section(sections, "blog") + if blog: + (out_dir / "script-blog.md").write_text(blog, encoding="utf-8") + artifacts.append({ + "filename": "script-blog.md", "name": "Blog post", "icon": "📰", + "description": "800-1200 word SEO-tuned blog version", + }) + + # ElevenLabs SSML (XML) + ssml = extract_ssml(package_md) + if ssml: + (out_dir / "ssml.xml").write_text(ssml, encoding="utf-8") + artifacts.append({ + "filename": "ssml.xml", "name": "ElevenLabs SSML", "icon": "🔊", + "description": "XML voice markup — paste into ElevenLabs", + }) + + # HeyGen paste-ready script (script with shot tags stripped) + if yt_long: + heygen_clean = strip_shot_tags(yt_long) + (out_dir / "heygen-script.txt").write_text(heygen_clean, encoding="utf-8") + artifacts.append({ + "filename": "heygen-script.txt", "name": "HeyGen-ready script", "icon": "🎭", + "description": "Shot tags stripped — paste into HeyGen avatar", + }) + + # Higgsfield B-roll prompts + broll = find_section(sections, "higgsfield") or find_section(sections, "b-roll") or find_section(sections, "broll") + if broll: + (out_dir / "broll-prompts.md").write_text(broll, encoding="utf-8") + artifacts.append({ + "filename": "broll-prompts.md", "name": "Higgsfield B-roll prompts", "icon": "🎥", + "description": "Image + motion prompts for each B-roll shot", + }) + + # Editing notes (for Jason) + edit_notes = find_section(sections, "editing", "notes") or find_section(sections, "jason") + if edit_notes: + (out_dir / "editing-notes.md").write_text(edit_notes, encoding="utf-8") + artifacts.append({ + "filename": "editing-notes.md", "name": "Editing notes (Jason)", "icon": "✂️", + "description": "Shot list, text overlay timing, pacing, thumbnail concept", + }) + + # Captions (all platforms) + captions = find_section(sections, "caption") + if captions: + (out_dir / "captions.md").write_text(captions, encoding="utf-8") + artifacts.append({ + "filename": "captions.md", "name": "Captions + hashtags", "icon": "💬", + "description": "Per-platform captions and hashtags", + }) + + # Metadata + metadata = { + "title": args.title or args.slug.replace("-", " ").title(), + "source_url": args.source_url, + "platform": args.platform or "—", + "duration_sec": args.duration_sec, + "word_count": args.word_count, + "tier": args.tier, + "created": datetime.now().strftime("%Y-%m-%d %H:%M"), + } + + # Render HTML index + html = render_html(args.slug, package_md, transcript_text, sections, artifacts, metadata) + (out_dir / "index.html").write_text(html, encoding="utf-8") + + # Manifest JSON + manifest = { + "slug": args.slug, + "created": metadata["created"], + "metadata": metadata, + "artifacts": artifacts, + } + (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + print(f"Delivered {len(artifacts)} artifacts to {out_dir}") + print(f"Open in browser: {out_dir / 'index.html'}") + + + +if __name__ == "__main__": + main() diff --git a/skills/transcript-repurposer/scripts/transcribe.bat b/skills/transcript-repurposer/scripts/transcribe.bat new file mode 100755 index 0000000..03d9d3f --- /dev/null +++ b/skills/transcript-repurposer/scripts/transcribe.bat @@ -0,0 +1,40 @@ +@echo off +REM Watts Transcript Tool — Windows wrapper for transcribe_local.py +REM +REM Setup (one time): +REM 1. Install Python from python.org (3.10 or newer) +REM 2. Open Command Prompt and run: pip install yt-dlp httpx +REM 3. Install ffmpeg: https://www.gyan.dev/ffmpeg/builds/ — add to PATH +REM 4. Make sure your Deepgram key is at: +REM C:\Users\\Documents\Claude\Skills\deepgram-key.txt +REM 5. Save this transcribe.bat anywhere convenient (Desktop, or in PATH) +REM +REM Usage: +REM transcribe https://www.youtube.com/watch?v=... +REM transcribe "https://www.instagram.com/reel/Cxxxx/" +REM transcribe C:\path\to\audio.mp3 + +setlocal +set SCRIPT_DIR=%~dp0 +set PY_SCRIPT=%SCRIPT_DIR%transcribe_local.py + +if "%~1"=="" ( + echo Usage: transcribe ^ + echo. + echo Examples: + echo transcribe https://www.youtube.com/watch?v=abc123 + echo transcribe "https://www.instagram.com/reel/Cxxx/" + echo transcribe C:\Videos\interview.mp3 + exit /b 1 +) + +REM Detect if input is a URL or a file path +set INPUT=%~1 +echo %INPUT% | findstr /R "^https*://" >nul +if %ERRORLEVEL% == 0 ( + python "%PY_SCRIPT%" --url "%INPUT%" +) else ( + python "%PY_SCRIPT%" --file "%INPUT%" +) + +endlocal diff --git a/skills/transcript-repurposer/scripts/transcribe_local.py b/skills/transcript-repurposer/scripts/transcribe_local.py new file mode 100755 index 0000000..9eeeed0 --- /dev/null +++ b/skills/transcript-repurposer/scripts/transcribe_local.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Local transcription CLI — runs on the USER'S Windows machine (NOT the Cowork sandbox). + +Why this exists: The Cowork bash sandbox cannot reach YouTube, Instagram, Deepgram, +or HuggingFace. So transcription has to happen OUTSIDE the sandbox. This script does +that on Graeham's actual computer using his Deepgram key, and writes the result into +the Documents\\Claude\\Skills\\_inbox\\ folder where Cowork can read it. + +Setup (one-time, on each machine that will use this): + pip install yt-dlp httpx + Set DEEPGRAM_API_KEY environment variable (or pass --key) + +Usage: + python transcribe_local.py --url "https://www.youtube.com/watch?v=..." + python transcribe_local.py --url "https://instagram.com/reel/..." --tier premium + python transcribe_local.py --file "C:\\path\\to\\audio.mp3" + +The output lands in Documents\\Claude\\Skills\\_inbox\\transcript-{slug}-{ts}.txt +plus a manifest .json with metadata. Cowork picks it up from there. +""" + +import argparse +import hashlib +import json +import os +import subprocess +import sys +import tempfile +import time +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + + +# Where transcripts land on the user's machine — Cowork can read this folder +INBOX_DIR = Path.home() / "Documents" / "Claude" / "Skills" / "_inbox" + + +def slugify(text: str, maxlen: int = 40) -> str: + import re + text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower() + return text[:maxlen] or "transcript" + + +def detect_platform(url: str) -> str: + host = urlparse(url).netloc.lower() + if "youtube.com" in host or "youtu.be" in host: return "youtube" + if "instagram.com" in host: return "instagram" + if "tiktok.com" in host: return "tiktok" + if "twitter.com" in host or "x.com" in host: return "x" + if "vimeo.com" in host: return "vimeo" + if "facebook.com" in host or "fb.watch" in host: return "facebook" + return "unknown" + + +def check_dependencies(): + missing = [] + try: + import yt_dlp # noqa + except ImportError: + missing.append("yt-dlp") + try: + import httpx # noqa + except ImportError: + missing.append("httpx") + if missing: + print(f"Missing dependencies: {missing}", file=sys.stderr) + print(f"Install with: pip install {' '.join(missing)}", file=sys.stderr) + sys.exit(2) + + +def get_video_metadata(url: str) -> dict: + import yt_dlp + opts = {"quiet": True, "no_warnings": True, "skip_download": True} + with yt_dlp.YoutubeDL(opts) as ydl: + try: + info = ydl.extract_info(url, download=False) + return { + "title": info.get("title", ""), + "uploader": info.get("uploader") or info.get("channel", ""), + "duration_sec": int(info.get("duration") or 0), + } + except Exception as e: + print(f"⚠ Metadata fetch failed: {e}", file=sys.stderr) + return {"title": "", "uploader": "", "duration_sec": 0} + + +def download_audio(url: str, workdir: Path) -> Path: + import yt_dlp + out_template = str(workdir / "audio.%(ext)s") + opts = { + "format": "bestaudio/best", + "outtmpl": out_template, + "quiet": True, + "no_warnings": True, + "postprocessors": [{ + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "192", + }], + } + with yt_dlp.YoutubeDL(opts) as ydl: + ydl.download([url]) + for ext in ("mp3", "m4a", "opus", "wav", "webm"): + p = workdir / f"audio.{ext}" + if p.exists(): + return p + raise FileNotFoundError(f"yt-dlp ran but no audio file produced in {workdir}") + + +def transcribe_deepgram(audio_path: Path, api_key: str) -> dict: + import httpx + url = "https://api.deepgram.com/v1/listen" + params = { + "model": "nova-3", + "smart_format": "true", + "punctuate": "true", + "paragraphs": "true", + } + headers = { + "Authorization": f"Token {api_key}", + "Content-Type": "audio/mp3", + } + with open(audio_path, "rb") as f: + audio_data = f.read() + with httpx.Client(timeout=300.0) as client: + r = client.post(url, params=params, headers=headers, content=audio_data) + if r.status_code != 200: + raise RuntimeError(f"Deepgram error {r.status_code}: {r.text[:200]}") + data = r.json() + text = data["results"]["channels"][0]["alternatives"][0]["transcript"].strip() + return {"text": text, "raw": data} + + +def main(): + parser = argparse.ArgumentParser(description="Local transcription CLI for Watts content pipeline") + src = parser.add_mutually_exclusive_group(required=True) + src.add_argument("--url", help="Video URL (any yt-dlp supported site)") + src.add_argument("--file", help="Local audio file path") + parser.add_argument("--tier", choices=["premium"], default="premium", + help="Currently only premium (Deepgram) supported — Whisper local needs separate install") + parser.add_argument("--key", help="Deepgram API key (defaults to DEEPGRAM_API_KEY env var)") + parser.add_argument("--inbox", default=str(INBOX_DIR), + help=f"Where to write the transcript (default: {INBOX_DIR})") + parser.add_argument("--slug", help="Override the auto-generated slug") + args = parser.parse_args() + + check_dependencies() + + api_key = args.key or os.environ.get("DEEPGRAM_API_KEY", "") + if not api_key: + # Try loading from the Documents persistence path + persistent_key = Path.home() / "Documents" / "Claude" / "Skills" / "deepgram-key.txt" + if persistent_key.exists(): + api_key = persistent_key.read_text().strip() + if not api_key: + print("✗ No Deepgram API key found. Pass --key or set DEEPGRAM_API_KEY env var,", file=sys.stderr) + print(f" or save the key to {persistent_key}", file=sys.stderr) + sys.exit(3) + + inbox = Path(args.inbox) + inbox.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d-%H%M") + + is_url = bool(args.url) + source = args.url or args.file + + print(f"→ Source: {source}") + print(f"→ Tier: {args.tier} (Deepgram Nova-3)") + print(f"→ Output inbox: {inbox}") + + metadata = {"title": "", "uploader": "", "duration_sec": 0} + platform = "local-file" + + with tempfile.TemporaryDirectory() as tmpdir: + workdir = Path(tmpdir) + + if is_url: + platform = detect_platform(source) + print(f"→ Platform: {platform}") + print(f"→ Fetching metadata...") + metadata = get_video_metadata(source) + if metadata["title"]: + print(f" Title: {metadata['title']}") + if metadata["duration_sec"]: + mins = metadata["duration_sec"] // 60 + secs = metadata["duration_sec"] % 60 + print(f" Duration: {mins}m{secs}s") + print(f"→ Downloading audio...") + t0 = time.time() + audio_path = download_audio(source, workdir) + print(f" Done in {time.time() - t0:.1f}s — {audio_path.stat().st_size // 1024} KB") + else: + audio_path = Path(source) + if not audio_path.exists(): + print(f"✗ Audio file not found: {audio_path}", file=sys.stderr) + sys.exit(4) + print(f"→ Using local audio file: {audio_path.stat().st_size // 1024} KB") + + print(f"→ Transcribing via Deepgram Nova-3...") + t0 = time.time() + result = transcribe_deepgram(audio_path, api_key) + elapsed = time.time() - t0 + print(f" Done in {elapsed:.1f}s") + + transcript_text = result["text"] + word_count = len(transcript_text.split()) + + # Generate slug from title or URL + slug = args.slug or slugify(metadata.get("title") or (urlparse(source).path.split("/")[-2] if is_url else "local")) + + # Write transcript text file + transcript_path = inbox / f"transcript-{slug}-{ts}.txt" + transcript_path.write_text(transcript_text, encoding="utf-8") + + # Write manifest JSON for Cowork to read + manifest = { + "transcript_file": transcript_path.name, + "source_url": source if is_url else None, + "source_file": source if not is_url else None, + "source_platform": platform, + "title": metadata["title"], + "uploader": metadata["uploader"], + "duration_sec": metadata["duration_sec"], + "word_count": word_count, + "tier": "deepgram-nova-3", + "transcribed_at": datetime.now().isoformat(), + "transcribe_seconds": round(elapsed, 1), + "status": "ready-for-cowork", + } + manifest_path = inbox / f"transcript-{slug}-{ts}.json" + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + print() + print(f"✓ Transcript ready ({word_count} words)") + print(f" Text: {transcript_path}") + print(f" Manifest: {manifest_path}") + print() + print("Next: In Cowork, say 'Repurpose the latest from my inbox' and the skill takes over.") + + +if __name__ == "__main__": + main() diff --git a/skills/travel-hq/SKILL.md b/skills/travel-hq/SKILL.md new file mode 100755 index 0000000..20b9820 --- /dev/null +++ b/skills/travel-hq/SKILL.md @@ -0,0 +1,230 @@ +--- +name: travel-hq +description: > + Dedicated travel agent, trip planner, and points strategist for Graeham Watts. Handles all travel planning, comparison, optimization, and booking prep. Use ANY time the user mentions: book a flight, book a hotel, plan a trip, travel planning, travel itinerary, trip comparison, flight search, hotel search, points optimization, credit card rewards for travel, lounge access, trip prep, packing list for a trip, travel emergency card, companion traveler, price drop monitor, post-trip review, points earned, miles earned, book a trip, trip prep brief, emergency travel card, travel HQ, travel assistant, what card should I use for flights, how many points will I earn, compare these trips, prep me for my trip, which airline, what lounge, or anything related to planning, comparing, booking, or reviewing travel. Also trigger when the user pastes a booking confirmation, flight itinerary, or hotel reservation. Over-trigger rather than under-trigger — if there is any travel intent in the message, use this skill. +--- + +# TRAVEL HQ + +You are Graeham's dedicated travel agent, trip planner, and points strategist inside Claude Cowork. Your job is to help plan, compare, optimize, and prep travel while following his preferences, loyalty programs, credit card benefits, and personal travel style. + +**Critical rule: Never book or purchase anything without Graeham's explicit approval. Always show options and wait for "Go" or "Book it."** + +When any of the command templates below are invoked, load `references/commands.md` and follow the relevant template exactly. + +--- + +## 1. MY TRAVEL PROFILE + +> **SETUP REQUIRED** — Replace all `[PLACEHOLDER]` fields with your real information before this skill is useful. + +Legal name for bookings: [LEGAL FIRST + LAST NAME] +Date of birth: [DOB] +Phone: [PHONE] +Email: [EMAIL] +Nationality / passport country: [COUNTRY] +Passport number: [PASSPORT NUMBER] +Passport expiration: [EXPIRATION DATE] +Known Traveler Number (KTN): [KTN] +Redress Number: [REDRESS NUMBER OR NONE] + +### Travel Style +Default: Efficient, comfortable, clean, low-stress + +Priority order: +1. Best schedule +2. Shortest travel time +3. Loyalty/status benefits +4. Comfort +5. Price +6. Points optimization + +> Do not optimize for lowest price unless Graeham explicitly says "cheapest possible." + +--- + +## 2. CREDIT CARDS, POINTS & PAYMENT + +Before recommending anything, always consider which card gives the best value, points, insurance, lounge access, and travel protections for this specific purchase. + +**Primary travel card:** [CARD NAME] +- Network: [Visa / Mastercard / Amex] +- Last 4: [XXXX] +- Best for: [Flights / hotels / dining / general] +- Benefits: [Trip delay, lounge, rental insurance] + +**Secondary card:** [CARD NAME] +- Last 4: [XXXX] +- Best for: [CATEGORY] + +**Hotel card:** [CARD NAME] +- Last 4: [XXXX] +- Benefits: [Free nights, elite status, upgrades] + +**Airline card:** [CARD NAME] +- Last 4: [XXXX] +- Benefits: [Free bags, priority boarding, lounges] + +### Points Programs + +For every purchase, show: cash price, points price, taxes/fees, cents-per-point value, and a clear recommendation on which is better. + +Minimum redemption values (don't redeem below these): +- Chase UR: 1.5 cpp +- Amex MR: 1.5 cpp +- Capital One: 1.3 cpp +- Airline miles: 1.3 cpm +- Hotel points: 0.7 cpp (adjust by program) + +--- + +## 3. AIRPORTS & FLIGHTS + +Primary airport: [YOUR AIRPORT CODE] +Backup airports: [BACKUP 1], [BACKUP 2] + +Seat preference: Aisle > Window > Never middle +Prefer: Exit row, extra legroom, front half of plane +Avoid: Last row, near bathrooms, basic economy + +### Cabin Rules +| Trip Length | Cabin | +|---|---| +| Under 5 hours | Economy or premium economy | +| 5+ hours | Premium economy or business | +| Overnight | Business or premium economy | +| Red-eyes | Avoid unless explicitly approved | +| Basic economy | Never book unless explicitly approved | + +### Schedule Rules +- Preferred departure: 7am–11am +- Acceptable: 6am–2pm +- Avoid: Before 6am, red-eyes, late-night arrivals +- Max connections: 1 stop +- Book direct if: Under $200 more than best 1-stop option +- Min connection time: 60 min domestic, 90 min international + +### Preferred Airlines +1. [AIRLINE 1] — ID: [XXXX] — Status: [TIER] +2. [AIRLINE 2] — ID: [XXXX] — Status: [TIER] +3. [AIRLINE 3] — ID: [XXXX] — Status: [TIER] + +**Always avoid:** Frontier, Spirit, Allegiant + +--- + +## 4. HOTELS & STAYS + +Style: Clean, modern, safe, conveniently located + +**Must-haves:** King bed, high floor (5th floor or above), fast WiFi, good gym, clean rooms, safe neighborhood, recent positive reviews, walking distance to anchor plans + +**Nice-to-haves:** Breakfast included, lounge access, spa, pool, late checkout, upgrade potential, good lobby vibe + +### Preferred Hotel Chains (priority order) +1. [CHAIN 1] — ID: [XXXX] — Status: [TIER] +2. [CHAIN 2] — ID: [XXXX] — Status: [TIER] +3. [CHAIN 3] — ID: [XXXX] — Status: [TIER] + +**Budget cap:** $[NUMBER]/night +Can exceed by $50 if location or quality clearly justifies it. Ask before exceeding beyond that. + +--- + +## 5. LOUNGES & AIRPORT EXPERIENCE + +TSA PreCheck: Yes — KTN: [KTN] +Global Entry: [Yes/No] +CLEAR: [Yes/No] +Arrival buffer: 75 min domestic, 2.5 hr international + +Lounge access cards: +- [CARD NAME]: [Centurion / Priority Pass / etc.] + +Always tell Graeham which lounges he can access at each relevant airport for a given trip. + +--- + +## 6. GROUND TRANSPORTATION + +Default: Uber Black after 9pm, regular Uber otherwise +Rental company: [COMPANY] — ID: [XXXX] +Preference order: Walking > Uber > transit > rental + +--- + +## 7. RESTAURANTS + +Favorite cuisines: [STEAK, SUSHI, ITALIAN, ETC.] +Style: Fun, high-quality, not overly touristy +Budget: $[NUMBER] per person per dinner +Reservation platforms: [OpenTable / Resy / Tock] + +--- + +## 8. INTERNATIONAL TRAVEL CHECKLIST + +For every international trip, always verify and flag: +- Passport validity (6 months beyond return date) +- Visa requirements for destination +- Required entry forms (ESTA, ETA, etc.) +- Recommended vaccinations +- Local currency and best way to get it +- Outlet adapters needed +- eSIM options vs. existing phone plan +- Tipping norms +- Best local ride-share apps +- Travel insurance coverage + +--- + +## HARD BOOKING RULES + +1. **Never book without explicit approval.** Wait for "Go" or "Book it." +2. **Before every booking, always show:** + - Recommended option + 1–2 alternatives + - Total cost including taxes and fees + - Cancellation policy + - Credit card to be charged (and why) + - Loyalty number being used + - Points/miles that will be earned + - Whether paying cash or redeeming points is the better value +3. **Show options in a clean comparison format** — not a wall of text. + +--- + +## COMPANION TRAVELER PROFILES + +When Graeham says "book for me + [name]", use these profiles: + +**Companion 1:** +- Legal name: [NAME] +- DOB: [DOB] +- KTN: [KTN] +- Loyalty IDs: [AIRLINE/HOTEL: NUMBER] +- Seat preference: [aisle/window] +- Meal preference: [if any] +- Notes: [allergies, mobility, etc.] + +**Companion 2:** *(same fields)* + +For trips with 3+ travelers, always ask if anyone else is coming. + +--- + +## AVAILABLE COMMANDS + +When Graeham uses any of these, load `references/commands.md` and follow the template for that command: + +- `# BOOK A TRIP` — flight + hotel search with full pre-booking summary +- `# TRIP PREP BRIEF` — single-page pre-trip briefing (weather, restaurants, logistics) +- `# PRICE DROP MONITOR` — daily tracking of a booked route for cheaper alternatives +- `# POST-TRIP REVIEW` — spending breakdown, points earned, profile update flags +- `# EMERGENCY TRAVEL CARD` — printable emergency contacts and logistics +- `# TRIP COMPARISON` — side-by-side analysis of two destination/date options + +--- + +## TONE + +Be direct, useful, and efficient. No excessive enthusiasm. When there's a clear best option, say so. When something is risky or overpriced, say that too. diff --git a/skills/travel-hq/references/commands.md b/skills/travel-hq/references/commands.md new file mode 100755 index 0000000..8942b40 --- /dev/null +++ b/skills/travel-hq/references/commands.md @@ -0,0 +1,156 @@ +# Travel HQ — Command Templates + +When Graeham invokes any of these commands (by pasting the header or a close variant), execute the template exactly as written. Use the travel profile and preferences in SKILL.md for all decisions. + +--- + +## BOOK A TRIP + +``` +Trip type: [Business / Leisure / Mixed] +Origin: [CITY or AIRPORT] +Destination: [CITY or AIRPORT] +Depart: [DATE] +Return: [DATE] +Travelers: [NAMES from profile + any companions] +Budget cap: $[NUMBER] total (flights + hotel) +Anchor plans: [meetings, dinners, events with times] +Hotel area: [neighborhood or 'close to anchor plans'] +``` + +**How to execute:** +1. Search for flights matching the schedule rules, cabin rules, and airline preferences from the travel profile. +2. Search for hotels matching the style, chain preferences, and budget cap from the travel profile. +3. Present the recommended option + 1–2 alternatives. For each option show: + - Airline, flight number, departure/arrival times, total travel time, number of stops + - Cabin class and seat availability + - Total cost with taxes and fees + - Cancellation policy + - Credit card to be charged (and why it's the best choice) + - Loyalty number being applied + - Points/miles that will be earned + - Whether cash or points is the better value +4. For hotels, show: property name, neighborhood, nightly rate, total cost, loyalty tier benefits, and any upgrade potential. +5. Do NOT book until Graeham says "Go" or "Book it." + +--- + +## TRIP PREP BRIEF + +``` +Trip: [CITY], [DATES] +Anchor plans: [list any fixed commitments] +``` + +**Produce a single page under 500 words with these sections (bold headers):** + +**WEATHER** — Daily forecast for each day of the trip. Flag anything that affects packing (rain, cold, heat, formal events). + +**DINNER RESERVATIONS** — 3 picks + 2 backups. For each: restaurant name, cuisine, vibe, price per person, reservation link (OpenTable/Resy/Tock), and whether reservations are needed urgently. Align with his cuisine preferences and budget from the profile. + +**COFFEE** — Top 2 coffee spots within 10 min walk of the hotel. + +**ONE NON-TOURISTY THING** — One thing to do on free time that isn't on every "top 10" list. + +**AIRPORT LOGISTICS** — Best lounge accessible at departure airport (based on card benefits). Estimated security wait time. Any good gate-area food options. + +**GROUND TRANSPORT** — Best way from airport to hotel. Cost and time estimate. + +**LOCAL NORMS** — Currency, tipping customs, cash vs. card, outlet type, any entry forms needed. + +**PERSONAL NOTE** — If this is a repeat city: what was done last time + one new pick. If first visit: one useful phrase in the local language. + +Use real names, real links, and real times. No filler. + +--- + +## PRICE DROP MONITOR + +``` +I just booked: [ROUTE], [DATES], [AIRLINE], [FARE CLASS], $[PRICE] +Booking ref: [PNR] +Change fee policy: [refundable / change fee $X / non-refundable] +``` + +**How to execute:** +Check the same route, dates, and cabin daily until departure. + +Alert Graeham **only if** the new price beats the booked price by more than the change/rebook cost AND the new booking preserves his elite benefits. + +When alerting, show: +- Old price +- New price +- Net savings after any change or rebook fee +- Exact action to take (cancel and rebook, call airline, use credit, etc.) + +If no alert-worthy drop exists, no message needed. + +--- + +## POST-TRIP REVIEW + +``` +Trip: [CITY], [DATES] +``` + +**Produce a review under 400 words with these sections:** + +**SPEND** +- Total spent, broken down by: flights / hotel / ground transport / food / other +- Cards used and what each one earned (points and cash back) +- Any point redemptions used and the cents-per-point value achieved + +**WHAT WORKED / WHAT DIDN'T** +- 3 things that went well +- 3 things to do differently next time + +**POINTS + STATUS** +- Total points/miles earned this trip +- Tier progress check: how close to the next status level, and what's needed to get there + +**PROFILE UPDATES** +- Flag anything in the travel profile that should change based on this trip (e.g., new preferred hotel discovered, airline to avoid, card that over-performed) + +Be direct. No padding. + +--- + +## EMERGENCY TRAVEL CARD + +``` +Trip: [CITY], [DATES] +``` + +**Build a clean, printable single page with:** + +- Airline phone numbers (main line + elite line if Graeham has status) +- Hotel: name, full address, phone number, confirmation number +- Nearest embassy/consulate for his nationality: address and phone number +- Local emergency numbers (police, ambulance, fire) +- Local ride-share app name and whether it accepts international credit cards +- Whether his phone plan works here or if he needs an eSIM (and recommended provider) +- 24/7 number for his primary travel credit card +- Travel insurance: policy name, policy number, and claims phone number + +No fluff. Just the information needed if something goes wrong at 2am. + +--- + +## TRIP COMPARISON + +``` +Option A: [DESTINATION / DATES] +Option B: [DESTINATION / DATES] +``` + +**For each option, show:** +- Total estimated trip cost: flights + hotel + estimated ground transport + food +- Door-to-door travel time +- Realistic cabin and hotel tier based on budget and loyalty status +- Points that would be earned vs. points that could be redeemed +- Weather and crowd level for those specific dates +- Top 3 things to do +- One strong reason to pick this option +- One honest reason to skip it + +**End with a clear recommendation** based on Graeham's travel profile, schedule preferences, and current points/status situation. Don't hedge — give a direct answer. diff --git a/skills/vaibhav-template/SKILL.md b/skills/vaibhav-template/SKILL.md new file mode 100755 index 0000000..b254c92 --- /dev/null +++ b/skills/vaibhav-template/SKILL.md @@ -0,0 +1,271 @@ +--- +name: vaibhav-template +description: Turn any script into a Vaibhav Sisinty-style talking-head video for Graeham Watts — the visual formula reverse-engineered from @vaibhavsisinty's 1.7M-follower Instagram reels. Fires on "run this script through the template I love", "make this Vaibhav-style", "use my video template", "convert to Vaibhav format", "add the warm desk aesthetic", "put this in my rotation", or any request to produce a video where Graeham's face appears with the burned-in visual system (locked talking-head, fast front-loaded cuts, serif-italic captions, color-highlighted keywords, emoji pop-ins). Encodes Graeham's 5 pre-generated warm-lit avatar looks (rotate across videos for variety without breaking brand consistency), the 5-mode composition system, cut pacing arc, and typography specs. Hands script + look off to heygen-video for rendering and higgsfield-video for B-roll. Use this instead of freestyling a HeyGen render when the user wants the Vaibhav aesthetic. +--- + +# Vaibhav-Style Video Template — Graeham Watts + +A reusable visual formula reverse-engineered from @vaibhavsisinty (1.7M followers, Instagram Reels). This skill doesn't make a video from scratch — it takes a script or topic and produces a shot plan, look choice, and caption spec that matches the Vaibhav aesthetic, then hands it to `heygen-video` for rendering and `higgsfield-video` for B-roll. + +## When this fires + +- "Run this script through the template I love" +- "Make this Vaibhav-style" +- "Use my video template on this" +- "Convert this script to the warm-desk look" +- "Add this to my content rotation" +- "Put this in the Vaibhav format" + +If the user has a topic but no script yet, chain with `content-creation-engine` first — that writes the script (it absorbed `video-script-creation-engine` in April 2026), then hand it back to this skill. + +--- + +## The reality + +**This is a STYLE skill, not a rendering skill.** The actual video comes out of `heygen-video`. This skill's job is to make sure every HeyGen video Graeham publishes has the same visual grammar — so viewers recognize his content within 2 seconds regardless of topic. + +Vaibhav's consistency is not an accident. His reels are recognizable within one frame because he locked in exactly five things: talking-head framing, cut pacing arc, typography, color grade, and caption placement. Those five things are this skill's entire contract. + +--- + +## Visual system — the 5 composition modes + +Every Vaibhav-style video is built from these 5 frames remixed. Don't invent new ones mid-video. + +### Mode 1 — Hook composite (seconds 0–3 ONLY) +- **Top 60% of frame:** full-bleed B-roll of the HOOK subject (Sam Altman, a listing, a map, a headline) +- **Bottom 40% of frame:** LOCKED talking-head of Graeham, same position every video +- **Caption on split line:** serif italic subject name top, sans-serif supporting clause underneath +- **Source files:** Top needs Higgsfield B-roll; bottom uses one of Graeham's 5 warm-desk looks +- **Used for:** Opening hook, re-engagement moment mid-video if attention drops + +### Mode 2 — Full-bleed talking head +- **Full frame:** Graeham's face, chest-up +- **Optional overlay:** red or orange color wash at 30% opacity (section transition) +- **Caption:** large serif italic center-frame +- **Used for:** Section transitions, high-emphasis statements, the "let me show you" pivot + +### Mode 3 — Full-bleed B-roll +- **Full frame:** environmental B-roll (street shot, listing exterior, neighborhood) +- **Caption:** small white-on-dark translucent pill, center or bottom-third +- **Used for:** Market data, location grounding, visual establishment + +### Mode 4 — Screenshot card (PiP) +- **Background:** solid dark/black +- **Top 60%:** product screenshot, document, or tool output (prompt card, listing MLS shot, Redfin page) +- **Bottom:** small inset of Graeham + big acid-green list number (`#01`, `#02`) + 3-line caption +- **Used for:** "Here's how to do it" content, tool walkthroughs, step-by-step + +### Mode 5 — Section header +- **Background:** current warm-desk look, faded/darkened +- **Watermark center-back:** PropOS logo or Graeham Watts Investment Properties mark at 20% opacity, large +- **Top overlay:** `##. TITLE CASE` in letter-spaced sans-serif +- **Used for:** Opening each numbered section in a listicle format + +--- + +## Cut rhythm — the documented arc + +From the reel analysis (80s, 52 cuts, scene-detection verified): + +| Section | % of runtime | Cut density | Shot length | +|---|---|---|---| +| Hook | first 12% | 40% of all cuts | ~0.5s each | +| Setup | 12–38% | 15% of all cuts | ~2.5s each | +| Body | 38–75% | 27% of all cuts | ~2.0s each | +| Climax | 75–88% | 13% of all cuts | ~1.4s each | +| CTA | final 12% | 6% of all cuts | ~3.0s each | + +**Key rule:** front-load 40% of your cuts in the first 10% of the runtime. This is what makes Vaibhav's style feel "fast" even though 80% of the video is moderately paced. Miss this and the video will feel slow no matter how good the rest is. + +**Translation to production:** for a 60-second video, plan ~16 cuts in the first 6 seconds, then ~20 cuts over the remaining 54s. + +--- + +## Typography — the distinctive signature + +His fonts are what make this aesthetic look premium instead of generic-viral. Most reels editors use bold sans-serif. He does the opposite. + +| Text type | Font | Weight | Color | Size | Example use | +|---|---|---|---|---|---| +| Primary subject | **Playfair Display** | Italic | White | Large (~80pt @ 1080p) | "Sam Altman", "Let me show you", place names | +| Secondary clause | **DM Sans** or **Inter** | Regular | White | Medium (~44pt) | "just killed the entire" | +| List numbers | **DM Sans Bold** | Bold | **Acid green** (`#BFFF00`) | Large | "**01.**", "**02.**", "**03.**" | +| Section titles | **DM Sans** | Medium, LETTER-SPACED 8% | White | Medium | "PRECISE TEXT", "REAL PRODUCT ACCURACY" | +| Burned-in captions | **Inter** | Semi-bold | White on translucent dark pill | Medium | Running dialogue captions | + +**Color-highlighted keywords:** 1–3 emphasis words per caption get acid-green or warm-yellow highlight boxes behind the text. Only highlight nouns and verbs that carry meaning — never articles, connectors, or filler. + +--- + +## Color & grade + +- **Base palette:** warm tungsten orange (~3200K) + cool dusk blue (~5600K), simultaneously. +- **Grading move:** subject is warmly lit; background is cool and blurred. This is Vaibhav's single most important color rule. +- **Section washes:** red (30% opacity, sharp transition) and gold (20% opacity, gentle transition) — use to mark scene shifts, not every cut. +- **Never:** flat white balance, cool-only grade, or gray grade. The warm/cool split IS the look. + +--- + +## Graeham's 5 looks — the rotation system + +Five avatar looks, each shares the "warm-lit desk with subject facing camera head-on, desk horizontal across bottom of frame" foundation but varies outfit + environment. Rotate across videos so viewers see variety but instant brand recognition. + +| Look name | Outfit | Environment | Best use | +|---|---|---|---| +| `warm_desk_navy` | Navy quarter-zip over white tee | Warm-lit home office, bookshelf + city window, brass lamp left | Everyday / default look — casual-professional | +| `podcast_studio` | Black crewneck | Acoustic foam panels, blue/teal dramatic accent lighting, SM7B broadcast mic visible | Hot takes, opinion content, long-form explainer | +| `loft_window` | Heather grey henley (top buttons undone) | Modern minimalist loft, blurred dusk city bokeh behind, warm lamp right edge | Lifestyle, neighborhood content, market storytelling | +| `corporate_office` | Charcoal suit jacket + light blue dress shirt + navy silk tie, **clear round wire-frame glasses** | Dark wood-paneled executive office, leather-bound books, brass desk lamp | Seller-facing content, CMA walkthroughs, listing presentations — "big deal" mode | +| `modern_studio` | Crisp white oxford, sleeves rolled, top button undone, **clear round wire-frame glasses** (same frames as corporate_office) | Clean minimalist studio, off-white/warm-grey backdrop, blonde-wood desk, brass arm lamp | Educational content, contract walkthroughs, "analyst mode" | + +**Rotation rule:** match look to content intent, not randomly. +- Market update → `warm_desk_navy` +- Bold take / podcast-style → `podcast_studio` +- Neighborhood / lifestyle → `loft_window` +- Seller meeting, listing intro, "I just sold this for $X" → `corporate_office` +- "Here's how a contract works" / educational → `modern_studio` + +**Don't rotate mid-video.** One look per video. Switching looks inside a single reel breaks identity. + +--- + +## The skill's workflow + +### Step 1: Understand the script's job + +Before doing anything, identify: +- **Content type:** market update / bold take / lifestyle / seller-facing / educational +- **Target runtime:** 30s / 60s / 90s — this drives cut planning +- **Hook payload:** what ONE specific thing in the first 3 seconds stops the scroll +- **CTA:** what action at the end + +Ask the user if any of these are unclear. Don't assume. + +### Step 2: Pick the look + +Match the content type to the look table above. State the pick with reasoning. Example: + +> "This is a market-data video about EPA pricing. I'd use `warm_desk_navy` — it's the everyday default and the data-driven tone doesn't need the gravitas of `corporate_office` or the studio polish of `modern_studio`. Good?" + +**Confirm before proceeding.** Don't assume the user wants the default. + +### Step 3: Build the shot plan + +Produce a table mapping each line/beat of the script to a composition mode + timing + caption spec. Example for a hook: + +``` +Time Mode Caption B-roll needed +0–1s Mode 1 "Sam Altman" (italic, top) portrait of person/subject + "just killed the entire" (bottom) +1–2s Mode 1 continues, crossfade caption same / slight zoom +2–3s Mode 2 "Image Gen Industry" Graeham full-bleed + (italic, yellow highlight on "Image") +3–4s Mode 1 "with one big launch" 4K industrial image +4–5s Mode 5 "01. PRECISE TEXT" Graeham warm_desk_navy + PropOS logo fade +``` + +Front-load cuts per the rhythm arc. Fill in the Body and Climax with Modes 3 and 4. End in Mode 2 with a clean CTA. + +### Step 4: List the B-roll Graeham needs + +For each beat that requires a B-roll clip (Modes 1, 3, and sometimes 4), produce an explicit Higgsfield prompt. Hand these to the `higgsfield-video` skill when the user is ready to shoot them. + +Example: +``` +B-roll #1 (beat at 0–3s): + Orientation: 16:9 (landscape) + Prompt: "4K ultra-detailed portrait of tech CEO in black turtleneck, + multiple facial expressions composited in a grid, dark + dramatic studio lighting, shot on Kodak Portra 400, Douglas + Friedman editorial style. Horizontal 16:9." + Duration: 4s + Motion: slow push-in +``` + +### Step 5: Hand off to heygen-video + +Once look + script are locked, call the `heygen-video` skill with: + +``` +python3 /path/to/heygen-video/scripts/create.py \ + --script "..." \ + --look \ + --aspect 16:9 \ + --title " - " +``` + +**Aspect:** All 5 Vaibhav looks are landscape-native (16:9). Graeham edits to portrait (9:16) in post. Always render at 16:9 from HeyGen. + +### Step 6: Build the caption + typography sheet + +Deliver a simple text file listing each caption beat, font, color, highlight words, and timing. Graeham's editor (or CapCut operator) uses this to burn in captions matching the Vaibhav typography spec. + +--- + +## What this skill does NOT do + +- ❌ Does not write the original script — use `content-creation-engine` or handle that separately +- ❌ Does not render video — always hands off to `heygen-video` +- ❌ Does not generate B-roll — produces prompts for `higgsfield-video` to execute +- ❌ Does not edit/composite final video — Graeham or his editor does this in CapCut / Premiere / After Effects using the shot plan + caption spec +- ❌ Does not let Graeham "freestyle" mid-video — one look, one grade, one typography system per video + +--- + +## Why consistency matters (and why to resist "just this once" deviations) + +Vaibhav's visual system looks simple because he never breaks it. 3,000+ posts at 1.7M followers — the same five composition modes, the same typography, the same warm/cool grade. Viewers recognize his content before the first word plays. That's the brand moat. + +If Graeham mixes looks mid-video, or swaps fonts for "variety", or front-loads 5 cuts instead of 20, he'll get a video that's *fine* but doesn't read as *his*. That's the difference between "another real estate agent posting on IG" and "the Peninsula guy who does those cinematic reels." + +**Strict adherence to this template IS the strategy.** + +--- + +## Quick reference card + +``` +Input: script + content_type +Output: shot plan + look choice + caption spec + B-roll prompts + HeyGen invocation + +LOOK DECISION TREE + market-data / everyday → warm_desk_navy + bold opinion / hot take → podcast_studio + lifestyle / neighborhood → loft_window + seller-facing / listing intro → corporate_office + educational / contract breakdown → modern_studio + +CUT RHYTHM + First 10% of runtime: 40% of all cuts (≈0.5s shots) + 10–38%: 15% of all cuts (≈2.5s shots) + 38–75%: 27% of all cuts (≈2.0s shots) + 75–88%: 13% of all cuts (≈1.4s shots) + Last 12%: 6% of all cuts (≈3.0s shots) + +TYPOGRAPHY + Subject/emphasis: Playfair Display Italic, white + Secondary: DM Sans Regular, white + List numbers: DM Sans Bold, acid-green #BFFF00 + Section titles: DM Sans Medium, letter-spaced 8%, UPPERCASE + Captions: Inter Semi-bold on dark translucent pill + +GRADE + Warm face (3200K) + cool background (5600K) = the signature + Section washes: red 30% (sharp), gold 20% (gentle) + +COMPOSITION MODES (use only these 5) + Mode 1 Hook composite (60% B-roll top / 40% locked Graeham bottom) + Mode 2 Full-bleed talking head + Mode 3 Full-bleed B-roll + Mode 4 Screenshot PiP card + Mode 5 Section header w/ logo watermark + +ASPECT: render HeyGen at 16:9. Edit to 9:16 in post. +``` + +--- + +## Session history + +Built 2026-04-23 with Graeham over a long working session. Reverse-engineered from an 80-second Vaibhav Sisinty reel (160 extracted frames, 52 scene-detected cuts, pacing arc measured per section). All 5 looks generated via Higgsfield Nano Banana Pro with identity-matched reference image, verified against Graeham's own selfie (`IMG_0520.JPG`). Anti-cleft prompt formula documented in `references/prompt_formula.md` — use this when regenerating or adding a 6th look. diff --git a/skills/vaibhav-template/references/looks.md b/skills/vaibhav-template/references/looks.md new file mode 100755 index 0000000..b6f7a5e --- /dev/null +++ b/skills/vaibhav-template/references/looks.md @@ -0,0 +1,125 @@ +# Graeham's 5 Warm-Desk Looks — Rotation System + +All 5 looks share the same composition foundation: subject facing camera head-on, desk running horizontally across the bottom of frame, 16:9 landscape native, warm-cool color grade, shallow depth of field with face as brightest element. + +Outfit + environment varies to give the content rotation variety without breaking identity consistency. + +--- + +## Look 1 — `warm_desk_navy` + +- **Outfit:** Navy blue quarter-zip pullover over white crewneck tee +- **Environment:** Warm-lit home office, dark wooden bookshelf on right, large window with blurred dusk city bokeh on left, warm tungsten desk lamp left edge +- **Props:** Laptop, leather notepad, coffee cup +- **Grade:** Warm gold + cool blue, warm dominant +- **Best use:** Everyday default. Market updates, buyer education, general content. If in doubt, this is the pick. +- **HeyGen look ID:** `67f9bcd8131140d793b9343851aeb25b` +- **Source file:** `C:\Users\Admin\Downloads\warm_desk_navy.png` + +--- + +## Look 2 — `podcast_studio` + +- **Outfit:** Black crewneck sweater +- **Environment:** Professional podcast studio with acoustic foam panels (dark charcoal + navy tones), Shure SM7B broadcast microphone on boom arm visible in left foreground +- **Props:** Mic + boom arm dominant, minimal desk clutter +- **Grade:** Dramatic low-key, blue/teal accent lighting, warm key light on face +- **Best use:** Hot takes, opinion content, long-form explainer, commentary on news/market events, "the truth about X" content. The moody grade signals gravity — save it for content where that tone is earned. +- **HeyGen look ID:** `e975c51279f3449991673293d47b99e2` +- **Source file:** `C:\Users\Admin\Downloads\podcast_studio.png` + +--- + +## Look 3 — `loft_window` + +- **Outfit:** Heather grey long-sleeve henley, top two buttons undone at collar +- **Environment:** Modern minimalist loft with floor-to-ceiling windows behind showing dramatic blurred dusk city bokeh with amber + teal pinpoint lights, exposed concrete accents +- **Props:** Sleek laptop left, ceramic coffee mug right, minimal desk +- **Grade:** Cool blue dusk background + warm amber key light from left +- **Best use:** Lifestyle content, neighborhood spotlights, "I love this city" storytelling, relocation content, community features. The cool dusk vibe communicates "end of workday reflection." +- **HeyGen look ID:** `798d3d001a4b44c9a0285621991aad1a` +- **Source file:** `C:\Users\Admin\Downloads\loft_window.png` + +--- + +## Look 4 — `corporate_office` + +- **Outfit:** Tailored charcoal grey wool suit jacket over crisp light blue dress shirt with navy blue silk necktie knotted at collar + clear round wire-frame glasses +- **Environment:** Executive office with dark wood-paneled walls, leather-bound books on shelf, large window with soft daylight one side, brass desk lamp casting warm light +- **Props:** Closed leather portfolio, fountain pen, white porcelain coffee cup +- **Grade:** Warm professional, magazine editorial polish +- **Best use:** Seller-facing content, listing introductions, CMA walkthroughs, "I just sold this for $X," high-stakes content, anything that needs executive gravitas. The tie + jacket signals "I'm handling something important." +- **HeyGen look ID:** `92ff2b057ef54b65863e627a30815e31` +- **Source file:** `C:\Users\Admin\Downloads\corporate_office.png` + +--- + +## Look 5 — `modern_studio` + +- **Outfit:** Crisp white oxford button-down with sleeves rolled to just below elbows, no tie, no jacket, top button undone + clear round wire-frame glasses (same frames as corporate_office for consistency) +- **Environment:** Clean modern minimalist studio with off-white/warm-grey seamless backdrop, tall brass arm lamp left edge, simple blonde-wood desk +- **Props:** Open notebook, black fountain pen, minimal +- **Grade:** Clean neutral with warm skin tones, bright even soft studio light +- **Best use:** Educational content, contract walkthroughs, "here's how this works" explainers, analyst-mode market breakdowns, PropOS product education. The clean backdrop and glasses signal "I'm about to explain something technical." +- **HeyGen look ID:** `3d52c06f1ab94c09881daef7cfe0743a` +- **Source file:** `C:\Users\Admin\Downloads\modern_studio.png` + +--- + +## Rotation logic + +Map content intent to look — don't randomize. + +| Content intent | Look | +|---|---| +| Market data / stat / price / metric | `warm_desk_navy` | +| Bold take / hot opinion / "here's what nobody's saying" | `podcast_studio` | +| Neighborhood / lifestyle / "I love it here" | `loft_window` | +| Seller-facing / listing intro / "just sold" | `corporate_office` | +| Educational / contract / process explainer | `modern_studio` | +| Unclear / casual / general | `warm_desk_navy` (default) | + +**Never mix looks inside a single video.** One look = one video. Continuity matters more than visual variety at the clip level. + +**Glasses consistency:** Two looks (corporate_office + modern_studio) use glasses. Use the same frames. When viewers see Graeham in glasses they should think "ah, he's about to explain something" — that signal only works if the frames match. + +--- + +## HeyGen upload status + +✅ **All 5 looks uploaded and registered in HeyGen** (avatar group `2160746aa659445e9cbfa4c02e5cf39c`). + +All 5 look IDs are filled in above. The `heygen-video` skill has been extended to recognize these 5 new looks via `--look ` — see `/mnt/skills/user/heygen-video/references/avatars.md` and the `LOOKS` dict in `/mnt/skills/user/heygen-video/scripts/create.py`. + +**To render a video in any of the 5 looks:** + +```bash +python3 /mnt/skills/user/heygen-video/scripts/create.py \ + --script "Your script text" \ + --look warm_desk_navy \ + --aspect 16:9 +``` + +Swap `warm_desk_navy` for any of: `podcast_studio`, `loft_window`, `corporate_office`, `modern_studio`. + +**All 5 are landscape-native** (16:9). Always pass `--aspect 16:9`; edit to portrait (9:16) in post. + +## If you add a 6th+ look later + +1. Generate it in Higgsfield Nano Banana Pro using the prompt formula in `references/prompt_formula.md` +2. Save the PNG to `C:\Users\Admin\Downloads\.png` +3. Upload to HeyGen web UI (the `upload.heygen.com` host is blocked from Claude's sandbox) +4. Name it exactly to match the filename +5. Tell Claude the look is uploaded — it will refetch via API and wire it in + +--- + +## Why these 5 (and not olive polo / pub_den) + +The original plan included `pub_den` (dark olive polo in warm wood-paneled den) but it was swapped mid-session for `corporate_office` (suit + tie + glasses) and `modern_studio` (white oxford + glasses). Reasons: + +- Olive polo was redundant with grey henley — both are casual daywear, no visual differentiation at playback size +- Glasses add a second-axis signifier (analyst mode vs. casual) that the outfit-only variation was missing +- Corporate + tie covers the seller-facing gravitas content that the prior 4 looks couldn't carry + +The final 5 cover: everyday (navy), bold-take (black), lifestyle (grey), executive (suit+glasses), educational (oxford+glasses). Each has a distinct content purpose and distinct visual signature. diff --git a/skills/vaibhav-template/references/prompt_formula.md b/skills/vaibhav-template/references/prompt_formula.md new file mode 100755 index 0000000..2452088 --- /dev/null +++ b/skills/vaibhav-template/references/prompt_formula.md @@ -0,0 +1,114 @@ +# Prompt Formula — Generating New Warm-Desk Looks + +This reference captures the exact prompt structure that produces identity-matched Graeham looks via Higgsfield's Nano Banana Pro model. Use this when adding a 6th+ look to the rotation, or regenerating any of the existing 5. + +## The four non-negotiables + +Every warm-desk look prompt must include these four blocks, in this order: + +### Block 1 — Identity lock (opens the prompt) + +``` +Photorealistic portrait matching the reference image face exactly. Same man +from the reference image — identical face shape, identical chin with smooth +rounded contour and absolutely no vertical cleft dimple or crease in the +chin, identical jaw contour, identical nose shape, identical eyes, identical +hairline and hair, identical complexion and skin tone. Match every facial +feature from the reference photo precisely including the smooth uncleft chin. +``` + +**Why this exact wording:** Nano Banana Pro has a documented bias toward adding chin clefts to white/Caucasian male subjects regardless of reference. Direct negation ("no vertical cleft dimple or crease") reduces but does not eliminate the drift. The redundant closing clause ("including the smooth uncleft chin") reinforces it. Expect ~30-40% improvement over prompts without this block; full elimination is not achievable with prompt engineering alone. + +### Block 2 — Camera + desk orientation + +``` +Camera positioned directly in front of the desk at eye level, square-on to +the subject. Desk runs horizontally across the bottom edge of the frame, +perpendicular to the camera, extending left-to-right, not diagonally. He is +centered in frame, shoulders square to camera, facing camera head-on. +``` + +**Why this exact wording:** Without explicit camera placement, Nano Banana defaults to 45° angled compositions where the desk extends diagonally away from camera. The "perpendicular" + "horizontally across the bottom" + "not diagonally" triplet is the minimum needed to force Vaibhav's head-on composition. Repetition is necessary — single-mention of "perpendicular" alone fails ~50% of the time. + +### Block 3 — Outfit (varies per look) + +Describe the full outfit in a single sentence. Be specific about material, fit, and layering. Add glasses as a separate sentence if applicable. + +**Known-good outfit descriptors:** +- `"navy blue quarter-zip pullover over a white crewneck tee"` → warm_desk_navy +- `"black crewneck sweater"` → podcast_studio +- `"heather grey long-sleeve henley shirt with the top two buttons undone at the collar"` → loft_window +- `"tailored charcoal grey wool suit jacket over a crisp light blue dress shirt with a navy blue silk necktie knotted neatly at the collar"` → corporate_office +- `"crisp white oxford button-down shirt with the sleeves rolled neatly up to just below the elbows, no tie, no jacket, top button undone"` → modern_studio + +**Glasses spec (when used):** `"modern clear round wire-frame glasses"`. Use the **same frame style across all looks that include glasses** — consistency makes them a recognizable signifier rather than visual noise. + +### Block 4 — Environment + lighting + props + +Describe background as heavily-blurred context, never competing with the face. Include: +- Wall/surface treatment (wood paneling, concrete, acoustic foam, seamless backdrop) +- A practical light source (lamp, window glow) +- 1-2 desk props max (laptop, notebook, coffee cup, fountain pen) +- Explicit instruction: `"His face is the brightest and sharpest element in frame."` + +## Words to AVOID (identity-drift risks) + +These words pull Nano Banana toward stock-photo "handsome-man" features (chin clefts, exaggerated jawlines, dramatic styling) and away from the reference: + +- `angular` — triggers chin cleft + sharp jaw exaggeration +- `confident` / `bold` / `strong` — triggers chin cleft + "stock photo" face +- `chiseled` / `sculpted` — obvious +- Photographer style references: `Douglas Friedman`, `Peter Lindbergh`, `Richard Avedon` — these bleed the photographer's signature face style + +**Safe style references:** +- `Kodak Portra 400` (film stock — no face bias) +- `cinematic warm color grading` (tonal, not structural) +- `editorial magazine quality` (generic enough to not overfit) + +## Generation settings (Higgsfield UI) + +- Model: **Nano Banana Pro** +- Aspect: **16:9** (landscape — all 5 looks are landscape-native) +- Quality: **4K** (5504×3072) +- Variants: **4/4** per batch +- Reference image: drag-and-drop the anchor selfie (`IMG_0520.JPG` or equivalent clean front-facing headshot). **The `file_upload` browser tool fails** — only drag-drop from File Explorer works. +- Cost: 16 credits per 4-variant batch + +## QC checklist before downloading a hero + +Verify each of these on the candidate before saving: + +- [ ] Camera is directly in front of the desk (not angled) +- [ ] Desk runs horizontally across the bottom, perpendicular to camera +- [ ] Shoulders are square to camera, subject is centered +- [ ] Outfit matches the spec exactly (color, garment type, layering, glasses if applicable) +- [ ] Lamp/practical light is positioned as prompted (foreground ≠ background) +- [ ] Face is the brightest and sharpest element in frame +- [ ] Chin looks as smooth as possible (know the AI bias — pick the variant with the least cleft) +- [ ] Hair, stubble, eyes, nose, complexion all match reference +- [ ] Background is heavily blurred and doesn't compete with face + +If a batch fails on more than two points, rewrite the prompt rather than rolling another 16-credit batch blindly. + +## The technical Higgsfield UI gotcha + +When typing a new prompt after clearing the old one, Chrome's `ctrl+a` on Higgsfield's contenteditable div does NOT select all — it inserts a literal `a` character. This caused a silent prefix bug (`aClose-up...`) on early generations this session. + +**Fix:** clear the prompt programmatically via JavaScript before typing: + +```javascript +const el = document.querySelector('[contenteditable="true"]'); +el.focus(); +const range = document.createRange(); +range.selectNodeContents(el); +const sel = window.getSelection(); +sel.removeAllRanges(); +sel.addRange(range); +document.execCommand('delete', false, null); +``` + +Then always screenshot-verify the prompt starts with the correct first word ("Photorealistic...") before clicking Generate. Do this every single time — the bug is subtle and wastes credits when missed. + +## Session evidence + +This formula was iteratively refined across ~112 Higgsfield credits (7 × 16-credit batches) during the April 23, 2026 build session. The final 5 approved looks — warm_desk_navy, podcast_studio, loft_window, corporate_office, modern_studio — all used this structure. The modern_studio generation (look #5) produced the cleanest chin result, likely because the clean editorial backdrop gave the model more "headroom" to render the reference face faithfully. diff --git a/skills/vaibhav-template/references/typography.md b/skills/vaibhav-template/references/typography.md new file mode 100755 index 0000000..a5688a4 --- /dev/null +++ b/skills/vaibhav-template/references/typography.md @@ -0,0 +1,118 @@ +# Typography & Caption Spec — Editor Handoff + +When Graeham's editor (or CapCut operator) burns captions into a Vaibhav-template video, they need to match this spec exactly. Copy-paste this file into the editor's handoff note. + +## Fonts to install + +Both are Google Fonts — free, no licensing issues. + +| Font family | Download | Used for | +|---|---|---| +| **Playfair Display** | fonts.google.com/specimen/Playfair+Display | All primary subject and emphasis text | +| **DM Sans** | fonts.google.com/specimen/DM+Sans | Secondary text, section titles, list numbers | +| **Inter** | fonts.google.com/specimen/Inter | Burned-in dialogue captions (smaller, dense text) | + +## Font weight + style reference + +| Text type | Family | Weight | Style | Size @ 1080×1920 | Color | +|---|---|---|---|---|---| +| Primary subject | Playfair Display | 400 (Regular) | *Italic* | 80–96pt | `#FFFFFF` white | +| Emphasis word | Playfair Display | 400 | *Italic* | 80–96pt | `#FFFFFF` on highlight box | +| Secondary clause | DM Sans | 400 (Regular) | Normal | 40–48pt | `#FFFFFF` white | +| List number | DM Sans | 700 (Bold) | Normal | 72–88pt | `#BFFF00` acid green | +| Section title | DM Sans | 500 (Medium) | Normal, **letter-spacing +8%** | 44–52pt | `#FFFFFF` on faded background | +| Dialogue caption | Inter | 600 (Semi-bold) | Normal | 36–44pt | `#FFFFFF` on translucent dark pill | + +**Pill spec for dialogue captions:** rounded rectangle, corner radius 12–16px, fill `#000000` at 60% opacity, 12px horizontal padding, 6px vertical padding, centered horizontally, bottom-third vertical anchor. + +## Color palette + +| Role | Hex | Notes | +|---|---|---| +| Primary text | `#FFFFFF` | All white; never tinted | +| Acid green highlight (numbers + emphasis) | `#BFFF00` | Saturated fluorescent green — this is the signature | +| Warm yellow highlight (occasional emphasis) | `#FFD700` | Use for 1 emphasis word per every 5–10 captions — sparse | +| Dark pill background | `#000000` @ 60% opacity | Never pure black at 100% — always translucent | +| Section wash red | `#FF3030` @ 30% opacity | Hard cut transition only | +| Section wash gold | `#D4A84B` @ 20% opacity | Gentle fade transition only | + +**Acid green is the anchor.** Every list number uses it. 1–2 emphasis keywords per 10-second block get it as a highlight-box fill. Don't dilute the signal by using it elsewhere. + +## Highlight box spec (for emphasis keywords) + +When a keyword gets a color highlight: +- Box extends 4px beyond the text on all sides +- Corner radius 2px (almost sharp — not pill-shaped) +- Box fill: acid green `#BFFF00` @ 100% opacity (solid, not translucent) +- Text on top: **still white** (`#FFFFFF`) — high contrast against green +- Rule: only highlight nouns and verbs that carry meaning. Never articles, connectors, prepositions, or filler. If the keyword doesn't survive the "would this still mean something standalone?" test, it doesn't get a highlight. + +## Caption placement zones + +Divide the 1080×1920 portrait frame (or 16:9 landscape if unusual) into: + +- **Top third (0–640px vertical):** primary subject captions (Mode 1 hook, Mode 5 section headers) +- **Middle third (640–1280px):** full-bleed talking-head emphasis (Mode 2) +- **Bottom third (1280–1920px):** dialogue captions, supporting clauses, Mode 4 card descriptors + +**Never center a caption in the middle third if Graeham's face is there.** Captions avoid the face zone. + +## Animation timing + +- **Caption in:** fade + slight 10% scale-up, 150ms duration +- **Caption out:** fade, 100ms duration, overlaps with next caption's fade-in +- **Crossfades:** do NOT hold captions static more than 2 seconds — refresh them in sync with Graeham's speech beats +- **Emphasis highlight box:** appears 50ms AFTER the text word does (tiny delay reads as intentional) + +## Caption writing rules + +- Keep captions to 3–7 words per on-screen moment +- Break long sentences into 2–3 sequential captions, not one long overlay +- Match the spoken word EXACTLY — never paraphrase +- Capitalize proper nouns; sentence-case everything else +- NO emoji inside captions (emoji is a separate layer, see below) + +## Emoji pop-ins (separate layer) + +Vaibhav uses emoji as small animated accents, not inside captions. They appear: +- Off to one side of the primary caption +- Scale in with a bounce (keyframed: 0% → 120% → 100%, ~250ms) +- Stay on screen 800ms–1.2s +- Scale out with a smaller bounce back to 0% + +**Common picks by content type:** +- Money / closing: 💰 🏠 ✅ +- Market / data: 📈 📊 📉 +- Neighborhood: 📍 🌉 (Bay) +- Analysis / explanation: 🤔 💡 👉 +- Hot take: 🔥 💀 (use sparingly) + +One emoji per on-screen caption moment. More than that = noise. + +## Example caption sheet (deliverable format) + +This is what the `vaibhav-template` skill outputs for the editor. Drop it into the shot plan table and the editor burns captions accordingly. + +``` +Time (s) Caption text Font + style Highlight Placement Emoji +0.0–1.2 Sam Altman Playfair Display Italic 88pt white — top third — +1.2–2.5 just killed the entire DM Sans Regular 44pt white — top third — +2.5–3.8 Image Gen Playfair Display Italic 88pt white on "Image Gen" center frame 🔥 (left side, 2.8–3.5s) + BFFF00 box +3.8–5.0 Industry with one big launch DM Sans Regular 44pt white — center frame — +5.0–6.5 01. PRECISE TEXT DM Sans Bold 80pt acid green (#BFFF00) — top third — + + Medium letter-spaced 8% +6.5–8.0 [dialogue caption] Inter Semi-bold 40pt white on dark pill — bottom third — +``` + +This format maps 1:1 to CapCut's text layer timeline — the editor just copies the row values into the layer properties. + +## What this spec does NOT cover + +- Music selection / audio design — separate pass +- Transitions between Modes 1–5 — Graeham has editor freedom on cut style (hard cut, whip pan, zoom blur) as long as rhythm pacing is respected +- Color grading LUT — the "warm face + cool background" is lit at capture time (the Higgsfield generation handles it) — post-grade only adjusts contrast and saturation + +## Source of truth + +If this file and SKILL.md disagree on any spec, **SKILL.md wins** — it's the higher-level document. This file is the editor-handoff translation of the SKILL.md rules, not a separate authority. diff --git a/skills/vaibhav-template/scripts/build_shot_plan.py b/skills/vaibhav-template/scripts/build_shot_plan.py new file mode 100755 index 0000000..13395eb --- /dev/null +++ b/skills/vaibhav-template/scripts/build_shot_plan.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +""" +build_shot_plan.py — Turn a script + content_type into a Vaibhav-style shot plan. + +Given a script, a content type, and a target runtime, produces: + - Look recommendation with reasoning + - Timed shot plan mapping each beat to a composition mode + - Caption sheet for the editor + - List of B-roll prompts needed from Higgsfield + - Ready-to-run HeyGen CLI invocation + +Usage: + python3 build_shot_plan.py --script "Your script here" --content-type market_data --runtime 60 + python3 build_shot_plan.py --from-file script.txt --content-type hot_take --runtime 90 + +This is a SCAFFOLDING tool — it gives you the structure. Graeham (or Claude) +still needs to refine the actual caption text, exact cut timings, and B-roll +descriptions. Think of the output as a 70%-done shot plan that you polish. +""" +from __future__ import annotations + +import argparse +import dataclasses +import math +import pathlib +import sys +import textwrap + + +# ========================================================================= +# LOOK DECISION TREE +# ========================================================================= + +LOOK_BY_CONTENT_TYPE = { + "market_data": ("warm_desk_navy", "Everyday default. Data-driven content doesn't need gravitas — warm desk navy is recognizable and versatile."), + "everyday": ("warm_desk_navy", "Default look. Casual-professional tone fits most general content."), + "hot_take": ("podcast_studio", "Moody podcast grade signals 'this is a bold opinion.' The darker environment earns the gravity of a strong take."), + "opinion": ("podcast_studio", "Podcast grade communicates this is commentary, not neutral reporting."), + "lifestyle": ("loft_window", "Dusk loft window cue reads as 'end of workday reflection' — fits lifestyle and neighborhood storytelling."), + "neighborhood": ("loft_window", "The blurred city bokeh grounds the video in 'place' without naming it explicitly."), + "seller_facing": ("corporate_office","Suit + tie + executive office communicates 'I'm handling something important.' Earn this look — don't use it for casual content."), + "listing_intro": ("corporate_office","Listing introductions are high-stakes content that benefits from executive polish."), + "educational": ("modern_studio", "Clean backdrop + glasses signal 'analyst mode / about to explain something.' The visual cue primes viewers to learn."), + "contract_explain":("modern_studio", "White oxford + glasses = 'contract walkthrough' visual cue. Viewers learn the signifier across videos."), + "propos_product": ("modern_studio", "PropOS educational content fits the analyst-mode visual signature."), +} + + +# ========================================================================= +# CUT RHYTHM ARC (from reel analysis: 80s video, 52 total cuts) +# ========================================================================= +# +# Section boundaries as fractions of runtime + cut share of total: +# Hook: first 12% of runtime, 40% of all cuts (~0.5s shots) +# Setup: 12-38%, 15% of all cuts (~2.5s shots) +# Body: 38-75%, 27% of all cuts (~2.0s shots) +# Climax: 75-88%, 13% of all cuts (~1.4s shots) +# CTA: last 12%, 6% of all cuts (~3.0s shots) + +SECTIONS = [ + {"name": "Hook", "runtime_start_pct": 0.00, "runtime_end_pct": 0.12, "cut_share_pct": 0.40, "shot_len_s": 0.5}, + {"name": "Setup", "runtime_start_pct": 0.12, "runtime_end_pct": 0.38, "cut_share_pct": 0.15, "shot_len_s": 2.5}, + {"name": "Body", "runtime_start_pct": 0.38, "runtime_end_pct": 0.75, "cut_share_pct": 0.27, "shot_len_s": 2.0}, + {"name": "Climax", "runtime_start_pct": 0.75, "runtime_end_pct": 0.88, "cut_share_pct": 0.13, "shot_len_s": 1.4}, + {"name": "CTA", "runtime_start_pct": 0.88, "runtime_end_pct": 1.00, "cut_share_pct": 0.06, "shot_len_s": 3.0}, +] + +# Typical mode distribution per section (composition modes from SKILL.md) +MODES_BY_SECTION = { + "Hook": ["Mode 1 (hook composite)", "Mode 1", "Mode 2 (talking head)", "Mode 1"], + "Setup": ["Mode 2", "Mode 3 (full-bleed B-roll)", "Mode 2"], + "Body": ["Mode 2", "Mode 4 (screenshot PiP)", "Mode 3", "Mode 2", "Mode 4"], + "Climax": ["Mode 2", "Mode 3", "Mode 2"], + "CTA": ["Mode 2"], +} + + +# ========================================================================= +# SHOT PLAN BUILDER +# ========================================================================= + +@dataclasses.dataclass +class Shot: + index: int + start_s: float + end_s: float + section: str + mode: str + needs_broll: bool + +def build_shot_plan(runtime_s: int) -> list[Shot]: + shots: list[Shot] = [] + index = 1 + total_target_cuts = max(6, round(runtime_s * 52 / 80)) # scale 52 cuts from 80s reference + + for section in SECTIONS: + start_s = section["runtime_start_pct"] * runtime_s + end_s = section["runtime_end_pct"] * runtime_s + n_cuts_in_section = max(1, round(total_target_cuts * section["cut_share_pct"])) + section_duration = end_s - start_s + shot_len = section_duration / n_cuts_in_section + + modes_for_section = MODES_BY_SECTION[section["name"]] + + for i in range(n_cuts_in_section): + shot_start = start_s + i * shot_len + shot_end = shot_start + shot_len + mode = modes_for_section[i % len(modes_for_section)] + needs_broll = "Mode 1" in mode or "Mode 3" in mode or "Mode 4" in mode + shots.append(Shot( + index=index, + start_s=round(shot_start, 2), + end_s=round(shot_end, 2), + section=section["name"], + mode=mode, + needs_broll=needs_broll, + )) + index += 1 + + return shots + + +# ========================================================================= +# OUTPUT FORMATTING +# ========================================================================= + +def render_shot_plan_table(shots: list[Shot]) -> str: + lines = [] + lines.append(f"{'#':>3} {'Time':<12} {'Section':<8} {'Mode':<30} {'B-roll?':<8}") + lines.append("-" * 75) + for s in shots: + time_range = f"{s.start_s:>5.1f}–{s.end_s:<5.1f}s" + broll_mark = "YES" if s.needs_broll else "—" + lines.append(f"{s.index:>3} {time_range:<12} {s.section:<8} {s.mode:<30} {broll_mark:<8}") + return "\n".join(lines) + + +def render_broll_todo(shots: list[Shot]) -> str: + broll_shots = [s for s in shots if s.needs_broll] + if not broll_shots: + return "No B-roll required — all shots are talking head (Mode 2).\n" + lines = [f"You need {len(broll_shots)} B-roll clips generated via higgsfield-video skill:\n"] + for s in broll_shots: + lines.append(f" [{s.start_s:>5.1f}s] {s.mode} — describe the visual content here, then run through higgsfield-video") + lines.append("\nEvery prompt must include Peninsula-specific anchors per higgsfield-video skill rules:") + lines.append(" - flat terrain (no hills)") + lines.append(" - San Francisco Bay visible in background") + lines.append(" - stucco ranch homes / Silicon Valley suburban character") + lines.append(" - name the specific neighborhood (Newbridge/Kavanaugh, The Gardens, West Side of 101)") + return "\n".join(lines) + + +def render_heygen_invocation(look: str, script: str, runtime_s: int) -> str: + return textwrap.dedent(f"""\ + # Once the shot plan above is final and the look is confirmed, render the talking-head base via heygen-video: + + python3 /mnt/skills/user/heygen-video/scripts/create.py \\ + --script {script!r} \\ + --look {look} \\ + --aspect 16:9 \\ + --title "Vaibhav template - {look} - $(date +%Y-%m-%d)" + + # The output will be a 16:9 MP4 of Graeham speaking the script at the chosen look. + # Estimated render time: 2-10 minutes for a ~{runtime_s}s script. + # Graeham's editor then: + # 1. Takes the HeyGen MP4 as the talking-head base layer + # 2. Cuts to B-roll at the times marked above + # 3. Burns captions per references/typography.md + # 4. Exports at 9:16 portrait for IG/TikTok (or keeps 16:9 for YouTube) + """).strip() + + +# ========================================================================= +# MAIN +# ========================================================================= + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + script_group = p.add_mutually_exclusive_group(required=True) + script_group.add_argument("--script", help="Script text directly on the command line.") + script_group.add_argument("--from-file", help="Path to a text file containing the script.") + p.add_argument("--content-type", required=True, choices=list(LOOK_BY_CONTENT_TYPE.keys()), + help="Content intent — determines which of the 5 looks to use.") + p.add_argument("--runtime", type=int, default=60, + help="Target runtime in seconds (default: 60).") + return p.parse_args() + + +def main() -> int: + args = parse_args() + + if args.from_file: + script = pathlib.Path(args.from_file).read_text().strip() + else: + script = args.script.strip() + + look, look_reason = LOOK_BY_CONTENT_TYPE[args.content_type] + shots = build_shot_plan(args.runtime) + + print("=" * 75) + print("VAIBHAV TEMPLATE — SHOT PLAN") + print("=" * 75) + print() + print(f"Content type: {args.content_type}") + print(f"Target runtime: {args.runtime}s") + print(f"Total shots: {len(shots)}") + print() + + print("-" * 75) + print("LOOK RECOMMENDATION") + print("-" * 75) + print(f"Use: {look}") + print(f"Why: {look_reason}") + print() + + print("-" * 75) + print("SCRIPT") + print("-" * 75) + wrapped = textwrap.fill(script, width=73, initial_indent=" ", subsequent_indent=" ") + print(wrapped) + print() + + print("-" * 75) + print("SHOT PLAN (map each beat of the script to a shot slot)") + print("-" * 75) + print(render_shot_plan_table(shots)) + print() + print("NOTE: the script has been broken into timed slots above, but you") + print("still need to assign actual caption text and B-roll content to each.") + print("This scaffold gives you the rhythm; Claude fills in the substance.") + print() + + print("-" * 75) + print("B-ROLL TODO LIST") + print("-" * 75) + print(render_broll_todo(shots)) + print() + + print("-" * 75) + print("HEYGEN RENDER COMMAND") + print("-" * 75) + print(render_heygen_invocation(look, script, args.runtime)) + print() + + print("-" * 75) + print("REMINDERS") + print("-" * 75) + print(" - Typography spec: references/typography.md") + print(" - Anti-cleft prompt formula for new B-roll of Graeham: references/prompt_formula.md") + print(" - Look details + HeyGen IDs: references/looks.md") + print(" - 40% of cuts go in the first 10% of runtime — this is what makes it feel fast") + print(" - Warm face / cool background — this is the single most important grade rule") + print(" - One look per video — never mix mid-video") + print() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/video-creator/SKILL.md b/skills/video-creator/SKILL.md new file mode 100755 index 0000000..56ac93e --- /dev/null +++ b/skills/video-creator/SKILL.md @@ -0,0 +1,242 @@ +--- +name: video-creator +description: "AI Video Creator — generates professional MP4 videos using Python + ffmpeg. Use this skill ANY time the user mentions: video, reel, short, TikTok, YouTube Short, Instagram Reel, listing video, property video, market update video, social media video, video content, create a video, make a video, video for social, animated video, slideshow video, video from photos, promo video, teaser video, explainer video, video presentation, or anything related to creating, rendering, or producing video content. Also trigger when the user wants to turn photos into a video, create motion graphics, make an animated market report, or produce any kind of video content from text or images. This skill renders finished MP4 files directly — no external tools or local setup needed." +--- + +# Video Creator Skill + +Create professional MP4 videos entirely within the Cowork environment. This skill uses Python (Pillow + OpenCV) for frame generation and ffmpeg for encoding. No browser, no Chromium, no local setup — just describe what you want and get a finished video. + +## Architecture + +``` +video-creator/ +├── SKILL.md ← You are here +└── scripts/ + ├── video_engine.py ← Core rendering engine (frames + ffmpeg) + ├── listing_video.py ← Real estate listing video template + ├── social_video.py ← Social media short-form template + └── market_video.py ← Market update / educational template +``` + +## How It Works + +1. **Understand the request** — what kind of video, what content, what format +2. **Build a config dict** — structured data describing every slide +3. **Call the appropriate template** — or build custom slides using the engine +4. **Render** — Python generates frames, ffmpeg encodes to H.264 MP4 +5. **Deliver** — save to outputs folder and provide download link + +## Quick Start + +Read the appropriate template script before generating video code. Each template accepts a JSON config and an output path. + +```python +import sys +sys.path.insert(0, '/scripts') + +# For listing videos: +from listing_video import create_listing_video +create_listing_video(config, '/sessions/.../mnt/outputs/my_video.mp4') + +# For social media: +from social_video import create_social_video +create_social_video(config, '/sessions/.../mnt/outputs/my_reel.mp4') + +# For market updates: +from market_video import create_market_video +create_market_video(config, '/sessions/.../mnt/outputs/market_update.mp4') + +# For fully custom videos: +from video_engine import * +project = VideoProject(slides=[...], output_path='...') +render_video(project) +``` + +## Video Types & When to Use Each + +### 1. Listing Video (`listing_video.py`) +Best for: Property showcases, open house promos, just-listed/just-sold announcements. + +**Config reference:** +```json +{ + "address": "123 Main Street", + "city_state_zip": "San Jose, CA 95125", + "price": "$1,850,000", + "beds": 4, + "baths": 3, + "sqft": "2,450", + "lot_size": "6,200 sqft", + "year_built": 1965, + "description": "Stunning mid-century modern home...", + "highlights": ["Renovated Kitchen", "Pool & Spa", "Top Schools"], + "photos": ["/path/to/photo1.jpg", "/path/to/photo2.jpg"], + "photo_captions": ["Living Room", "Kitchen"], + "agent_name": "Graeham Watts", + "agent_title": "REALTOR® | DRE# 01466876", + "agent_contact": "graehamwatts@gmail.com", + "agent_phone": "408-XXX-XXXX", + "brokerage": "Compass", + "theme": "luxury", + "aspect_ratio": "landscape", + "duration_per_photo": 4.0, + "include_highlights": true, + "cta_text": "Schedule Your Private Tour" +} +``` + +**Slide flow:** Title → Property Stats → Photo slides (with Ken Burns) → Highlights → Description → CTA + +### 2. Social Media Video (`social_video.py`) +Best for: Instagram Reels, YouTube Shorts, TikTok, quick tips, market stats. + +**Types available:** +- `"tips"` — Hook headline → numbered tip slides → CTA +- `"stats"` — Headline → stat cards with big numbers → CTA +- `"teaser"` — Quick photo montage with address overlay → CTA +- `"quote"` — Client testimonial or inspirational quote → CTA + +**Config reference:** +```json +{ + "type": "tips", + "headline": "3 Mistakes First-Time Buyers Make", + "items": ["Not getting pre-approved first", "Skipping the inspection", "Waiving contingencies"], + "background_image": "/path/to/bg.jpg", + "agent_name": "Graeham Watts", + "agent_handle": "@graehamwatts", + "theme": "luxury", + "aspect_ratio": "portrait" +} +``` + +For stats type: +```json +{ + "type": "stats", + "headline": "Bay Area Market Update", + "stats": [ + {"label": "Median Price", "value": "$1.85M", "change": "+4.2%"}, + {"label": "Days on Market", "value": "12", "change": "-3 days"}, + {"label": "Inventory", "value": "1.8 months", "change": "-15%"} + ], + "agent_name": "Graeham Watts", + "agent_handle": "@graehamwatts", + "theme": "luxury", + "aspect_ratio": "portrait" +} +``` + +### 3. Market Update Video (`market_video.py`) +Best for: Monthly market reports, educational content, data presentations. + +**Config reference:** +```json +{ + "title": "Silicon Valley Market Update", + "subtitle": "March 2026", + "sections": [ + { + "headline": "Median Home Price", + "stat_value": "$1.85M", + "stat_label": "Median Price", + "stat_change": "+4.2%", + "content": "Prices continue to climb as inventory remains tight." + }, + { + "headline": "Market Velocity", + "stat_value": "12 Days", + "stat_label": "Average Days on Market", + "stat_change": "-3 days", + "content": "Homes are selling faster than last quarter." + } + ], + "takeaways": [ + "Sellers still have strong leverage in most price ranges", + "Well-priced homes are getting multiple offers within a week", + "Interest rates are stabilizing, bringing more buyers back" + ], + "agent_name": "Graeham Watts", + "agent_title": "REALTOR® | DRE# 01466876", + "agent_contact": "graehamwatts@gmail.com", + "theme": "luxury", + "aspect_ratio": "landscape" +} +``` + +### 4. Custom Video (use `video_engine.py` directly) +For anything that doesn't fit the templates — fully custom slide sequences. + +**Available components:** +- `Slide` — base slide with background color/image, overlays, text, transitions +- `TextOverlay` — text with font, size, color, animation, background pill, shadow +- `LowerThird` — professional bar with headline + subtitle +- `VideoProject` — container for slides + encoding settings + +**Transitions:** `FADE`, `DISSOLVE`, `SLIDE_LEFT`, `SLIDE_RIGHT`, `ZOOM_IN`, `WIPE_LEFT`, `KENBURNS`, `CUT` + +**Text Animations:** `FADE_IN`, `SLIDE_UP`, `TYPEWRITER`, `SCALE_IN`, `NONE` + +**Ken Burns directions:** `zoom_in`, `zoom_out`, `pan_left`, `pan_right` + +## Themes + +All templates support these color themes: + +| Theme | Best For | Primary Color | +|-------|----------|---------------| +| `luxury` | High-end listings, premium branding | Navy + Gold | +| `modern` | Clean contemporary look | Dark + Blue accent | +| `coastal` | Beach/waterfront properties | Ocean blue + Teal | +| `warm` | Cozy homes, family neighborhoods | Warm brown + Gold | +| `minimal` | Ultra-clean, minimal design | White + Black | +| `bold` | Attention-grabbing social content | Black + Red | +| `clean` | Light professional look | Light gray + Dark | + +Note: Not all themes are available in all templates. `luxury` and `modern` are universally supported. + +## Aspect Ratios + +| Setting | Resolution | Use Case | +|---------|-----------|----------| +| `landscape` | 1920×1080 | YouTube, website, presentations | +| `portrait` | 1080×1920 | Instagram Reels, TikTok, YouTube Shorts | +| `square` | 1080×1080 | Instagram feed, Facebook | + +## Working with Photos + +When the user provides photos (uploaded or from a folder): +1. Photos are at paths under `/sessions/.../mnt/uploads/` or the user's selected folder +2. Pass absolute paths in the config's `photos` array +3. The engine handles resizing, cropping (cover fit), and Ken Burns effects automatically +4. Supported formats: JPG, PNG, WebP, TIFF + +If no photos are provided, the skill creates text-only slides with colored backgrounds — still professional and useful. + +## Performance Notes + +- A 30-second video at 30fps = ~900 frames. Expect 2-4 minutes render time. +- For faster test renders, use `fps=24` or even `fps=15`. +- Shorter videos (10-15 seconds) render in under a minute. +- Social media portrait videos are smaller (1080px wide) and render faster. + +## Agent Info Defaults + +When the user doesn't specify agent info, use these defaults for Graeham: +- Name: Graeham Watts +- Title: REALTOR® | DRE# 01466876 +- Email: graehamwatts@gmail.com +- Brokerage: Compass + +## Step-by-Step Workflow + +1. **Ask what kind of video** if not clear from the request +2. **Read the relevant template script** to understand the config shape +3. **Build the config** from the user's input (fill in defaults for missing fields) +4. **Write a Python script** that imports the template and calls it with the config +5. **Run the script** via Bash with a timeout of 300000ms (5 min) +6. **Save output** to `/sessions/.../mnt/outputs/` and provide a computer:// link +7. **If the user wants changes**, modify the config and re-render + +Always tell the user roughly how long rendering will take based on duration and fps. diff --git a/skills/video-creator/generated/.gitkeep b/skills/video-creator/generated/.gitkeep new file mode 100755 index 0000000..e69de29 diff --git a/skills/video-creator/scripts/listing_video.py b/skills/video-creator/scripts/listing_video.py new file mode 100755 index 0000000..aa9bf73 --- /dev/null +++ b/skills/video-creator/scripts/listing_video.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +""" +Listing Video Template — Property showcase with photos, text overlays, transitions. +Designed for real estate agents to create professional listing videos. + +Usage: + python3 listing_video.py --config listing_config.json --output listing.mp4 + +Or import and use programmatically: + from listing_video import create_listing_video + create_listing_video(config, output_path) +""" + +import argparse +import json +import os +import random +import sys +from typing import Dict, List, Optional + +# Add parent dir to path +sys.path.insert(0, os.path.dirname(__file__)) +from video_engine import ( + COLORS, FONT_SANS, FONT_SANS_BOLD, FONT_SERIF, FONT_SERIF_BOLD, + LowerThird, Slide, TextAnimation, TextOverlay, + Transition, VideoProject, create_cta_slide, create_photo_slide, + create_text_slide, create_title_slide, render_video, +) + + +# ─── Color Themes ──────────────────────────────────────────────────────────── + +THEMES = { + "luxury": { + "bg_primary": (15, 25, 50), + "bg_secondary": (245, 240, 230), + "accent": (198, 168, 124), + "text_light": (255, 255, 255), + "text_dark": (30, 30, 30), + "overlay": (0, 0, 0, 150), + }, + "modern": { + "bg_primary": (25, 25, 25), + "bg_secondary": (250, 250, 250), + "accent": (60, 140, 200), + "text_light": (255, 255, 255), + "text_dark": (30, 30, 30), + "overlay": (0, 0, 0, 130), + }, + "coastal": { + "bg_primary": (20, 60, 90), + "bg_secondary": (240, 248, 255), + "accent": (100, 200, 200), + "text_light": (255, 255, 255), + "text_dark": (20, 40, 60), + "overlay": (10, 30, 50, 140), + }, + "warm": { + "bg_primary": (60, 30, 20), + "bg_secondary": (255, 250, 240), + "accent": (210, 160, 90), + "text_light": (255, 255, 255), + "text_dark": (40, 30, 20), + "overlay": (30, 15, 10, 140), + }, + "minimal": { + "bg_primary": (255, 255, 255), + "bg_secondary": (245, 245, 245), + "accent": (40, 40, 40), + "text_light": (255, 255, 255), + "text_dark": (30, 30, 30), + "overlay": (0, 0, 0, 120), + }, +} + + +# ─── Listing Video Builder ─────────────────────────────────────────────────── + +def create_listing_video(config: Dict, output_path: str, + width: int = 1920, height: int = 1080, + fps: int = 30) -> str: + """ + Create a complete listing video from a config dict. + + Config shape: + { + "address": "123 Main Street", + "city_state_zip": "San Jose, CA 95125", + "price": "$1,850,000", + "beds": 4, + "baths": 3, + "sqft": "2,450", + "lot_size": "6,200 sqft", # optional + "year_built": 1965, # optional + "description": "Stunning mid-century modern...", # optional + "highlights": ["Renovated Kitchen", "Pool & Spa", ...], + "photos": ["/path/to/photo1.jpg", ...], + "photo_captions": ["Living Room", "Kitchen", ...], # optional + "agent_name": "Graeham Watts", + "agent_title": "REALTOR® | DRE# 01466876", + "agent_contact": "graehamwatts@gmail.com", + "agent_phone": "408-XXX-XXXX", # optional + "brokerage": "Compass", # optional + "theme": "luxury", # luxury, modern, coastal, warm, minimal + "aspect_ratio": "landscape", # landscape, portrait, square + "duration_per_photo": 4.0, # seconds per photo slide + "include_highlights": true, + "cta_text": "Schedule Your Private Tour", + } + """ + + theme_name = config.get("theme", "luxury") + theme = THEMES.get(theme_name, THEMES["luxury"]) + + # Aspect ratio + ar = config.get("aspect_ratio", "landscape") + if ar == "portrait": + width, height = 1080, 1920 + elif ar == "square": + width, height = 1080, 1080 + + photo_duration = config.get("duration_per_photo", 4.0) + photos = config.get("photos", []) + captions = config.get("photo_captions", []) + highlights = config.get("highlights", []) + + slides = [] + + # ── 1. Title Slide ──────────────────────────────────────────────────── + # Use first photo as background if available + title_bg = photos[0] if photos else None + price_line = config.get("price", "") + address = config.get("address", "Beautiful Home") + city = config.get("city_state_zip", "") + + subtitle_parts = [] + if price_line: + subtitle_parts.append(price_line) + if city: + subtitle_parts.append(city) + subtitle = " | ".join(subtitle_parts) + + title_slide = Slide( + duration=4.5, + background_color=theme["bg_primary"], + image_path=title_bg, + overlay_color=theme["overlay"], + transition_in=Transition.FADE, + transition_duration=1.0, + texts=[ + TextOverlay( + text=address.upper(), + position=(_center_x(width, 700), height // 2 - 90), + font_path=FONT_SANS_BOLD, + font_size=68 if len(address) < 25 else 52, + color=theme["text_light"], + max_width=700 if ar == "landscape" else 500, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + ), + TextOverlay( + text=subtitle, + position=(_center_x(width, 700), height // 2 + 10), + font_path=FONT_SANS, + font_size=30, + color=theme["accent"], + max_width=700, + align="center", + animation=TextAnimation.SLIDE_UP, + shadow=True, + ), + ], + ) + + # Add stats bar + stats_parts = [] + if config.get("beds"): + stats_parts.append(f"{config['beds']} Beds") + if config.get("baths"): + stats_parts.append(f"{config['baths']} Baths") + if config.get("sqft"): + stats_parts.append(f"{config['sqft']} Sqft") + if stats_parts: + title_slide.texts.append(TextOverlay( + text=" • ".join(stats_parts), + position=(_center_x(width, 700), height // 2 + 60), + font_path=FONT_SANS, + font_size=28, + color=theme["text_light"], + max_width=700, + align="center", + animation=TextAnimation.SLIDE_UP, + shadow=True, + )) + + slides.append(title_slide) + + # ── 2. Property Stats Slide ────────────────────────────────────────── + stat_items = [] + if config.get("beds"): + stat_items.append(f"Bedrooms: {config['beds']}") + if config.get("baths"): + stat_items.append(f"Bathrooms: {config['baths']}") + if config.get("sqft"): + stat_items.append(f"Living Area: {config['sqft']} sqft") + if config.get("lot_size"): + stat_items.append(f"Lot Size: {config['lot_size']}") + if config.get("year_built"): + stat_items.append(f"Year Built: {config['year_built']}") + + if stat_items: + # Use second photo as background if available + stat_bg = photos[1] if len(photos) > 1 else None + stat_slide = Slide( + duration=4.0, + background_color=theme["bg_primary"], + image_path=stat_bg, + overlay_color=(0, 0, 0, 180) if stat_bg else None, + blur_background=True if stat_bg else False, + transition_in=Transition.FADE, + transition_duration=0.6, + texts=[ + TextOverlay( + text="PROPERTY DETAILS", + position=(120, 100) if ar == "landscape" else (80, 200), + font_path=FONT_SANS_BOLD, + font_size=42, + color=theme["accent"], + animation=TextAnimation.FADE_IN, + shadow=True, + ), + TextOverlay( + text="\n".join(stat_items), + position=(120, 180) if ar == "landscape" else (80, 280), + font_path=FONT_SANS, + font_size=34, + color=theme["text_light"], + max_width=width - 240, + animation=TextAnimation.SLIDE_UP, + shadow=True, + line_spacing=18, + ), + ], + ) + slides.append(stat_slide) + + # ── 3. Photo Slides ────────────────────────────────────────────────── + # Alternate Ken Burns directions for visual interest + kb_directions = ["zoom_in", "zoom_out", "pan_left", "pan_right"] + transitions = [Transition.KENBURNS, Transition.FADE, Transition.DISSOLVE, Transition.SLIDE_LEFT] + + for i, photo in enumerate(photos): + caption = captions[i] if i < len(captions) else "" + kb_dir = kb_directions[i % len(kb_directions)] + trans = transitions[i % len(transitions)] + + slide = Slide( + duration=photo_duration, + image_path=photo, + transition_in=trans if trans != Transition.KENBURNS else Transition.KENBURNS, + transition_duration=0.6, + kenburns_direction=kb_dir, + ) + + # Add caption if provided + if caption: + slide.lower_third = LowerThird( + headline=caption, + bar_color=theme["bg_primary"], + accent_color=theme["accent"], + text_color=theme["text_light"], + ) + + slides.append(slide) + + # ── 4. Highlights Slide ────────────────────────────────────────────── + if highlights and config.get("include_highlights", True): + highlights_slide = Slide( + duration=5.0, + background_color=theme["bg_secondary"], + transition_in=Transition.FADE, + transition_duration=0.5, + texts=[ + TextOverlay( + text="PROPERTY HIGHLIGHTS", + position=(120, 80) if ar == "landscape" else (80, 200), + font_path=FONT_SANS_BOLD, + font_size=44, + color=theme["text_dark"], + animation=TextAnimation.FADE_IN, + shadow=False, + ), + TextOverlay( + text="\n".join(f"✦ {h}" for h in highlights[:8]), + position=(140, 170) if ar == "landscape" else (80, 300), + font_path=FONT_SANS, + font_size=32, + color=theme["text_dark"], + max_width=width - 280, + animation=TextAnimation.SLIDE_UP, + shadow=False, + line_spacing=16, + ), + ], + ) + slides.append(highlights_slide) + + # ── 5. Description Slide (optional) ────────────────────────────────── + if config.get("description"): + desc_bg = photos[-1] if photos else None + desc_slide = Slide( + duration=5.0, + background_color=theme["bg_primary"], + image_path=desc_bg, + overlay_color=(0, 0, 0, 190) if desc_bg else None, + blur_background=True, + transition_in=Transition.FADE, + transition_duration=0.6, + texts=[ + TextOverlay( + text=config["description"][:300], + position=(120, height // 2 - 100) if ar == "landscape" else (80, height // 2 - 200), + font_path=FONT_SERIF, + font_size=30, + color=theme["text_light"], + max_width=width - 240, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + line_spacing=14, + ), + ], + ) + slides.append(desc_slide) + + # ── 6. CTA / Contact Slide ─────────────────────────────────────────── + cta_text = config.get("cta_text", "Schedule Your Private Tour") + agent_name = config.get("agent_name", "") + agent_title = config.get("agent_title", "") + agent_contact = config.get("agent_contact", "") + agent_phone = config.get("agent_phone", "") + brokerage = config.get("brokerage", "") + + contact_parts = [] + if agent_phone: + contact_parts.append(agent_phone) + if agent_contact: + contact_parts.append(agent_contact) + contact_line = " | ".join(contact_parts) + + agent_line = agent_name + if agent_title: + agent_line += f" • {agent_title}" + + cta_slide = Slide( + duration=5.0, + background_color=theme["bg_primary"], + transition_in=Transition.FADE, + transition_duration=1.0, + texts=[ + TextOverlay( + text=cta_text, + position=(_center_x(width, 800), height // 2 - 120), + font_path=FONT_SANS_BOLD, + font_size=56, + color=theme["text_light"], + max_width=800, + align="center", + animation=TextAnimation.SCALE_IN, + shadow=True, + ), + TextOverlay( + text=agent_line, + position=(_center_x(width, 800), height // 2 + 0), + font_path=FONT_SANS_BOLD, + font_size=30, + color=theme["accent"], + max_width=800, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + ), + TextOverlay( + text=contact_line, + position=(_center_x(width, 800), height // 2 + 50), + font_path=FONT_SANS, + font_size=26, + color=(200, 200, 200), + max_width=800, + align="center", + animation=TextAnimation.FADE_IN, + ), + ], + ) + + if brokerage: + cta_slide.texts.append(TextOverlay( + text=brokerage, + position=(_center_x(width, 800), height // 2 + 100), + font_path=FONT_SANS, + font_size=22, + color=(160, 160, 160), + max_width=800, + align="center", + animation=TextAnimation.FADE_IN, + )) + + slides.append(cta_slide) + + # ── Build & Render ─────────────────────────────────────────────────── + project = VideoProject( + slides=slides, + width=width, + height=height, + fps=fps, + output_path=output_path, + background_music=config.get("background_music"), + music_volume=config.get("music_volume", 0.3), + ) + + def progress(current, total): + pct = int(current / total * 100) + if pct % 10 == 0: + print(f" Rendering: {pct}%", flush=True) + + return render_video(project, progress_callback=progress) + + +def _center_x(width: int, content_width: int) -> int: + """Calculate x position to center content.""" + return (width - content_width) // 2 + + +# ─── CLI ───────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Create a listing video") + parser.add_argument("--config", required=True, help="Path to JSON config file") + parser.add_argument("--output", default="listing_video.mp4", help="Output path") + parser.add_argument("--width", type=int, default=1920) + parser.add_argument("--height", type=int, default=1080) + parser.add_argument("--fps", type=int, default=30) + args = parser.parse_args() + + with open(args.config) as f: + config = json.load(f) + + create_listing_video(config, args.output, args.width, args.height, args.fps) diff --git a/skills/video-creator/scripts/market_video.py b/skills/video-creator/scripts/market_video.py new file mode 100755 index 0000000..9d6e3d8 --- /dev/null +++ b/skills/video-creator/scripts/market_video.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +Market Update / Educational Video Template — Longer-form content for +market reports, educational explainers, and data-driven presentations. + +Config shape: +{ + "type": "market_update" | "explainer", + "title": "Silicon Valley Market Update", + "subtitle": "March 2026", + "sections": [ + { + "headline": "Median Home Price", + "content": "Prices rose 4.2% year-over-year to $1.85M", + "stat_value": "$1.85M", + "stat_label": "Median Price", + "stat_change": "+4.2%", + "image": "/path/to/chart.png" # optional + } + ], + "takeaways": ["Key point 1", "Key point 2"], + "agent_name": "Graeham Watts", + "agent_title": "REALTOR® | DRE# 01466876", + "agent_contact": "graehamwatts@gmail.com", + "theme": "luxury", + "aspect_ratio": "landscape", +} +""" + +import json +import os +import sys +from typing import Dict, List + +sys.path.insert(0, os.path.dirname(__file__)) +from video_engine import ( + COLORS, FONT_SANS, FONT_SANS_BOLD, FONT_SERIF, + LowerThird, Slide, TextAnimation, TextOverlay, Transition, + VideoProject, render_video, +) + + +THEMES = { + "luxury": { + "bg_dark": (15, 25, 50), + "bg_light": (245, 240, 230), + "accent": (198, 168, 124), + "text_on_dark": (255, 255, 255), + "text_on_light": (30, 30, 30), + "stat_color": (198, 168, 124), + "positive": (80, 200, 120), + "negative": (255, 100, 100), + }, + "modern": { + "bg_dark": (20, 20, 25), + "bg_light": (248, 248, 252), + "accent": (60, 130, 220), + "text_on_dark": (255, 255, 255), + "text_on_light": (30, 30, 40), + "stat_color": (60, 130, 220), + "positive": (50, 205, 130), + "negative": (255, 85, 85), + }, +} + + +def create_market_video(config: Dict, output_path: str) -> str: + """Create a market update or educational video.""" + theme_name = config.get("theme", "luxury") + theme = THEMES.get(theme_name, THEMES["luxury"]) + + ar = config.get("aspect_ratio", "landscape") + if ar == "portrait": + w, h = 1080, 1920 + elif ar == "square": + w, h = 1080, 1080 + else: + w, h = 1920, 1080 + + slides = [] + + # ── Title Slide ────────────────────────────────────────────────────── + title = config.get("title", "Market Update") + subtitle = config.get("subtitle", "") + + slides.append(Slide( + duration=4.0, + background_color=theme["bg_dark"], + transition_in=Transition.FADE, + transition_duration=0.8, + texts=[ + TextOverlay( + text=title.upper(), + position=(_cx(w, w - 200), h // 2 - 80), + font_path=FONT_SANS_BOLD, + font_size=60 if len(title) < 30 else 46, + color=theme["text_on_dark"], + max_width=w - 200, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + ), + TextOverlay( + text=subtitle, + position=(_cx(w, w - 200), h // 2 + 10), + font_path=FONT_SANS, + font_size=30, + color=theme["accent"], + max_width=w - 200, + align="center", + animation=TextAnimation.SLIDE_UP, + ), + ], + )) + + # ── Section Slides ─────────────────────────────────────────────────── + sections = config.get("sections", []) + for i, section in enumerate(sections): + # Alternate between dark and light backgrounds + is_dark = i % 2 == 0 + bg = theme["bg_dark"] if is_dark else theme["bg_light"] + text_color = theme["text_on_dark"] if is_dark else theme["text_on_light"] + + texts = [] + + # Section headline + texts.append(TextOverlay( + text=section.get("headline", "").upper(), + position=(120, 80) if ar == "landscape" else (80, 200), + font_path=FONT_SANS_BOLD, + font_size=42, + color=theme["accent"], + max_width=w - 240, + animation=TextAnimation.FADE_IN, + shadow=is_dark, + )) + + # If there's a big stat value, show it prominently + if section.get("stat_value"): + texts.append(TextOverlay( + text=section["stat_value"], + position=(_cx(w, w - 200), h // 2 - 60), + font_path=FONT_SANS_BOLD, + font_size=96, + color=theme["stat_color"], + max_width=w - 200, + align="center", + animation=TextAnimation.SCALE_IN, + shadow=is_dark, + )) + + if section.get("stat_label"): + texts.append(TextOverlay( + text=section["stat_label"], + position=(_cx(w, w - 200), h // 2 - 110), + font_path=FONT_SANS, + font_size=26, + color=text_color, + max_width=w - 200, + align="center", + animation=TextAnimation.FADE_IN, + shadow=is_dark, + )) + + if section.get("stat_change"): + change = section["stat_change"] + change_color = theme["positive"] if change.startswith("+") else theme["negative"] + texts.append(TextOverlay( + text=change, + position=(_cx(w, w - 200), h // 2 + 50), + font_path=FONT_SANS_BOLD, + font_size=40, + color=change_color, + max_width=w - 200, + align="center", + animation=TextAnimation.SLIDE_UP, + )) + + # Content text (if no stat, or as supporting text) + if section.get("content"): + content_y = h // 2 + 100 if section.get("stat_value") else h // 2 - 40 + texts.append(TextOverlay( + text=section["content"], + position=(120, content_y) if ar == "landscape" else (80, content_y), + font_path=FONT_SANS, + font_size=30, + color=text_color, + max_width=w - 240, + animation=TextAnimation.SLIDE_UP, + shadow=is_dark, + line_spacing=12, + )) + + slide = Slide( + duration=5.0, + background_color=bg, + image_path=section.get("image"), + overlay_color=(0, 0, 0, 170) if section.get("image") else None, + transition_in=Transition.FADE, + transition_duration=0.5, + texts=texts, + ) + slides.append(slide) + + # ── Key Takeaways ──────────────────────────────────────────────────── + takeaways = config.get("takeaways", []) + if takeaways: + slides.append(Slide( + duration=6.0, + background_color=theme["bg_light"], + transition_in=Transition.FADE, + transition_duration=0.5, + texts=[ + TextOverlay( + text="KEY TAKEAWAYS", + position=(120, 80) if ar == "landscape" else (80, 200), + font_path=FONT_SANS_BOLD, + font_size=44, + color=theme["text_on_light"], + animation=TextAnimation.FADE_IN, + shadow=False, + ), + TextOverlay( + text="\n".join(f"→ {t}" for t in takeaways), + position=(140, 170) if ar == "landscape" else (80, 300), + font_path=FONT_SANS, + font_size=30, + color=theme["text_on_light"], + max_width=w - 280, + animation=TextAnimation.SLIDE_UP, + shadow=False, + line_spacing=20, + ), + ], + )) + + # ── CTA / Agent Slide ──────────────────────────────────────────────── + agent_name = config.get("agent_name", "") + agent_title = config.get("agent_title", "") + agent_contact = config.get("agent_contact", "") + + cta_texts = [] + if agent_name: + cta_texts.append(TextOverlay( + text=agent_name, + position=(_cx(w, w - 600), h // 2 - 60), + font_path=FONT_SANS_BOLD, + font_size=48, + color=theme["text_on_dark"], + max_width=600, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + )) + if agent_title: + cta_texts.append(TextOverlay( + text=agent_title, + position=(_cx(w, w - 600), h // 2 + 10), + font_path=FONT_SANS, + font_size=26, + color=theme["accent"], + max_width=600, + align="center", + animation=TextAnimation.SLIDE_UP, + )) + if agent_contact: + cta_texts.append(TextOverlay( + text=agent_contact, + position=(_cx(w, w - 600), h // 2 + 50), + font_path=FONT_SANS, + font_size=24, + color=(180, 180, 180), + max_width=600, + align="center", + animation=TextAnimation.FADE_IN, + )) + + slides.append(Slide( + duration=4.0, + background_color=theme["bg_dark"], + transition_in=Transition.FADE, + transition_duration=0.8, + texts=cta_texts, + )) + + # ── Render ─────────────────────────────────────────────────────────── + project = VideoProject( + slides=slides, + width=w, + height=h, + fps=30, + output_path=output_path, + background_music=config.get("background_music"), + music_volume=config.get("music_volume", 0.3), + ) + + def progress(current, total): + pct = int(current / total * 100) + if pct % 10 == 0: + print(f" Rendering: {pct}%", flush=True) + + return render_video(project, progress_callback=progress) + + +def _cx(w, content_w): + return (w - content_w) // 2 + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--output", default="market_video.mp4") + args = parser.parse_args() + + with open(args.config) as f: + config = json.load(f) + create_market_video(config, args.output) diff --git a/skills/video-creator/scripts/social_video.py b/skills/video-creator/scripts/social_video.py new file mode 100755 index 0000000..969d696 --- /dev/null +++ b/skills/video-creator/scripts/social_video.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +""" +Social Media Video Template — Short-form vertical videos for Reels/Shorts/TikTok. +Designed for real estate tips, market stats, quick property teasers. + +Usage: + python3 social_video.py --config social_config.json --output reel.mp4 + +Config shape: +{ + "type": "tips" | "stats" | "teaser" | "quote", + "headline": "3 Things Buyers Miss", + "items": ["Item 1", "Item 2", "Item 3"], + "stats": [{"label": "Median Price", "value": "$1.2M", "change": "+5.2%"}], + "background_image": "/path/to/image.jpg", # optional + "agent_name": "Graeham Watts", + "agent_handle": "@graehamwatts", + "theme": "luxury", + "aspect_ratio": "portrait", # portrait (default), landscape, square +} +""" + +import json +import os +import sys +from typing import Dict, List + +sys.path.insert(0, os.path.dirname(__file__)) +from video_engine import ( + COLORS, FONT_SANS, FONT_SANS_BOLD, FONT_SERIF, FONT_SERIF_BOLD, + LowerThird, Slide, TextAnimation, TextOverlay, Transition, + VideoProject, render_video, +) + + +THEMES = { + "luxury": { + "bg": (15, 25, 50), + "accent": (198, 168, 124), + "text": (255, 255, 255), + "highlight_bg": (198, 168, 124, 220), + "card_bg": (25, 40, 70, 220), + }, + "modern": { + "bg": (20, 20, 20), + "accent": (60, 180, 220), + "text": (255, 255, 255), + "highlight_bg": (60, 180, 220, 220), + "card_bg": (35, 35, 35, 220), + }, + "bold": { + "bg": (0, 0, 0), + "accent": (255, 80, 80), + "text": (255, 255, 255), + "highlight_bg": (255, 80, 80, 220), + "card_bg": (20, 20, 20, 220), + }, + "clean": { + "bg": (250, 250, 250), + "accent": (40, 40, 40), + "text": (30, 30, 30), + "highlight_bg": (40, 40, 40, 220), + "card_bg": (255, 255, 255, 220), + }, +} + + +def create_social_video(config: Dict, output_path: str) -> str: + """Create a social media video from config.""" + video_type = config.get("type", "tips") + theme_name = config.get("theme", "luxury") + theme = THEMES.get(theme_name, THEMES["luxury"]) + + ar = config.get("aspect_ratio", "portrait") + if ar == "portrait": + width, height = 1080, 1920 + elif ar == "square": + width, height = 1080, 1080 + else: + width, height = 1920, 1080 + + if video_type == "tips": + slides = _build_tips_video(config, theme, width, height) + elif video_type == "stats": + slides = _build_stats_video(config, theme, width, height) + elif video_type == "teaser": + slides = _build_teaser_video(config, theme, width, height) + elif video_type == "quote": + slides = _build_quote_video(config, theme, width, height) + else: + slides = _build_tips_video(config, theme, width, height) + + project = VideoProject( + slides=slides, + width=width, + height=height, + fps=30, + output_path=output_path, + background_music=config.get("background_music"), + music_volume=config.get("music_volume", 0.3), + ) + + def progress(current, total): + pct = int(current / total * 100) + if pct % 20 == 0: + print(f" Rendering: {pct}%", flush=True) + + return render_video(project, progress_callback=progress) + + +def _build_tips_video(config, theme, w, h): + """Build a tips-style video: hook → numbered items → CTA.""" + headline = config.get("headline", "Tips You Need to Know") + items = config.get("items", ["Tip 1", "Tip 2", "Tip 3"]) + bg_image = config.get("background_image") + + slides = [] + + # Hook slide + slides.append(Slide( + duration=3.0, + background_color=theme["bg"], + image_path=bg_image, + overlay_color=(0, 0, 0, 170) if bg_image else None, + transition_in=Transition.FADE, + transition_duration=0.5, + texts=[ + TextOverlay( + text=headline.upper(), + position=(_cx(w, w - 160), h // 2 - 80), + font_path=FONT_SANS_BOLD, + font_size=64 if len(headline) < 30 else 48, + color=theme["text"], + max_width=w - 160, + align="center", + animation=TextAnimation.SCALE_IN, + shadow=True, + ), + # Accent underline via text + TextOverlay( + text="▬" * 8, + position=(_cx(w, w - 160), h // 2 + 20), + font_path=FONT_SANS_BOLD, + font_size=24, + color=theme["accent"], + max_width=w - 160, + align="center", + animation=TextAnimation.FADE_IN, + ), + ], + )) + + # Individual tip slides + for i, item in enumerate(items): + number_text = f"{i + 1:02d}" + slides.append(Slide( + duration=3.5, + background_color=theme["bg"], + image_path=bg_image, + overlay_color=(0, 0, 0, 180) if bg_image else None, + transition_in=Transition.SLIDE_LEFT, + transition_duration=0.4, + texts=[ + # Big number + TextOverlay( + text=number_text, + position=(_cx(w, w - 160), h // 2 - 160), + font_path=FONT_SANS_BOLD, + font_size=120, + color=theme["accent"], + max_width=w - 160, + align="center", + animation=TextAnimation.SCALE_IN, + shadow=False, + ), + # Tip text + TextOverlay( + text=item, + position=(_cx(w, w - 160), h // 2 + 0), + font_path=FONT_SANS_BOLD, + font_size=40, + color=theme["text"], + max_width=w - 160, + align="center", + animation=TextAnimation.SLIDE_UP, + shadow=True, + line_spacing=12, + ), + ], + )) + + # CTA slide + agent_name = config.get("agent_name", "") + handle = config.get("agent_handle", "") + slides.append(_make_cta_slide(agent_name, handle, theme, w, h)) + + return slides + + +def _build_stats_video(config, theme, w, h): + """Build a market stats video: headline → stat cards → CTA.""" + headline = config.get("headline", "Market Update") + stats = config.get("stats", []) + bg_image = config.get("background_image") + + slides = [] + + # Headline + slides.append(Slide( + duration=2.5, + background_color=theme["bg"], + transition_in=Transition.FADE, + transition_duration=0.5, + texts=[ + TextOverlay( + text=headline.upper(), + position=(_cx(w, w - 160), h // 2 - 60), + font_path=FONT_SANS_BOLD, + font_size=56, + color=theme["text"], + max_width=w - 160, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + ), + ], + )) + + # Each stat gets its own slide + for stat in stats: + label = stat.get("label", "") + value = stat.get("value", "") + change = stat.get("change", "") + + texts = [ + TextOverlay( + text=label.upper(), + position=(_cx(w, w - 160), h // 2 - 140), + font_path=FONT_SANS, + font_size=32, + color=theme["accent"], + max_width=w - 160, + align="center", + animation=TextAnimation.FADE_IN, + ), + TextOverlay( + text=value, + position=(_cx(w, w - 160), h // 2 - 80), + font_path=FONT_SANS_BOLD, + font_size=96, + color=theme["text"], + max_width=w - 160, + align="center", + animation=TextAnimation.SCALE_IN, + shadow=True, + ), + ] + if change: + change_color = (80, 200, 120) if change.startswith("+") else (255, 100, 100) + texts.append(TextOverlay( + text=change, + position=(_cx(w, w - 160), h // 2 + 40), + font_path=FONT_SANS_BOLD, + font_size=44, + color=change_color, + max_width=w - 160, + align="center", + animation=TextAnimation.SLIDE_UP, + )) + + slides.append(Slide( + duration=3.5, + background_color=theme["bg"], + transition_in=Transition.FADE, + transition_duration=0.4, + texts=texts, + )) + + # CTA + slides.append(_make_cta_slide( + config.get("agent_name", ""), + config.get("agent_handle", ""), + theme, w, h, + )) + + return slides + + +def _build_teaser_video(config, theme, w, h): + """Build a property teaser — quick photos with stats overlay.""" + photos = config.get("photos", []) + address = config.get("address", "") + price = config.get("price", "") + + slides = [] + + # Quick flash through photos + for i, photo in enumerate(photos[:6]): + slide = Slide( + duration=2.0, + image_path=photo, + transition_in=Transition.KENBURNS if i % 2 == 0 else Transition.SLIDE_LEFT, + transition_duration=0.3, + kenburns_direction=["zoom_in", "pan_left", "zoom_out", "pan_right"][i % 4], + overlay_color=(0, 0, 0, 60), + ) + + # Address + price on first slide + if i == 0 and (address or price): + slide.overlay_color = (0, 0, 0, 140) + if address: + slide.texts.append(TextOverlay( + text=address.upper(), + position=(_cx(w, w - 120), h // 2 - 50), + font_path=FONT_SANS_BOLD, + font_size=52, + color=theme["text"], + max_width=w - 120, + align="center", + animation=TextAnimation.SCALE_IN, + shadow=True, + )) + if price: + slide.texts.append(TextOverlay( + text=price, + position=(_cx(w, w - 120), h // 2 + 30), + font_path=FONT_SANS_BOLD, + font_size=44, + color=theme["accent"], + max_width=w - 120, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + )) + + slides.append(slide) + + # CTA + slides.append(_make_cta_slide( + config.get("agent_name", ""), + config.get("agent_handle", ""), + theme, w, h, + )) + + return slides + + +def _build_quote_video(config, theme, w, h): + """Build a quote/testimonial video.""" + quote = config.get("quote", config.get("headline", "")) + attribution = config.get("attribution", "") + bg_image = config.get("background_image") + + slides = [ + Slide( + duration=6.0, + background_color=theme["bg"], + image_path=bg_image, + overlay_color=(0, 0, 0, 170) if bg_image else None, + blur_background=True if bg_image else False, + transition_in=Transition.FADE, + transition_duration=0.8, + texts=[ + TextOverlay( + text=f'"{quote}"', + position=(_cx(w, w - 200), h // 2 - 100), + font_path=FONT_SERIF, + font_size=38, + color=theme["text"], + max_width=w - 200, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + line_spacing=16, + ), + TextOverlay( + text=f"— {attribution}" if attribution else "", + position=(_cx(w, w - 200), h // 2 + 60), + font_path=FONT_SANS, + font_size=28, + color=theme["accent"], + max_width=w - 200, + align="center", + animation=TextAnimation.SLIDE_UP, + ), + ], + ), + _make_cta_slide( + config.get("agent_name", ""), + config.get("agent_handle", ""), + theme, w, h, + ), + ] + + return slides + + +def _make_cta_slide(agent_name, handle, theme, w, h): + """Reusable CTA slide.""" + texts = [] + if agent_name: + texts.append(TextOverlay( + text=agent_name, + position=(_cx(w, w - 160), h // 2 - 40), + font_path=FONT_SANS_BOLD, + font_size=44, + color=theme["text"], + max_width=w - 160, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + )) + if handle: + texts.append(TextOverlay( + text=handle, + position=(_cx(w, w - 160), h // 2 + 30), + font_path=FONT_SANS, + font_size=30, + color=theme["accent"], + max_width=w - 160, + align="center", + animation=TextAnimation.SLIDE_UP, + )) + texts.append(TextOverlay( + text="FOLLOW FOR MORE", + position=(_cx(w, w - 160), h // 2 + 80), + font_path=FONT_SANS_BOLD, + font_size=24, + color=(160, 160, 160), + max_width=w - 160, + align="center", + animation=TextAnimation.FADE_IN, + )) + + return Slide( + duration=3.0, + background_color=theme["bg"], + transition_in=Transition.FADE, + transition_duration=0.6, + texts=texts, + ) + + +def _cx(width, content_width): + return (width - content_width) // 2 + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument("--output", default="social_video.mp4") + args = parser.parse_args() + + with open(args.config) as f: + config = json.load(f) + create_social_video(config, args.output) diff --git a/skills/video-creator/scripts/video_engine.py b/skills/video-creator/scripts/video_engine.py new file mode 100755 index 0000000..99f27b3 --- /dev/null +++ b/skills/video-creator/scripts/video_engine.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python3 +""" +Video Creator Engine — Core rendering pipeline. +Uses Pillow for frame generation and ffmpeg for encoding. +Designed for real estate content: listing videos, social clips, market updates. +""" + +import json +import math +import os +import subprocess +import tempfile +import textwrap +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import List, Optional, Tuple + +from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageEnhance + +# ─── Constants ──────────────────────────────────────────────────────────────── + +FPS = 30 +FONT_DIR = "/usr/share/fonts/opentype/urw-base35" + +# Aspect ratios +LANDSCAPE = (1920, 1080) # 16:9 YouTube / standard +PORTRAIT = (1080, 1920) # 9:16 Reels / Shorts / TikTok +SQUARE = (1080, 1080) # 1:1 Instagram feed + +# Font paths (clean, professional sans-serif) +FONT_SANS = os.path.join(FONT_DIR, "NimbusSans-Regular.otf") +FONT_SANS_BOLD = os.path.join(FONT_DIR, "NimbusSans-Bold.otf") +FONT_SERIF = os.path.join(FONT_DIR, "NimbusRoman-Regular.otf") +FONT_SERIF_BOLD = os.path.join(FONT_DIR, "NimbusRoman-Bold.otf") + +# Color palette — real estate professional +COLORS = { + "white": (255, 255, 255), + "black": (0, 0, 0), + "dark_gray": (30, 30, 30), + "charcoal": (45, 45, 45), + "medium_gray": (120, 120, 120), + "light_gray": (200, 200, 200), + "off_white": (245, 245, 245), + "gold": (198, 168, 124), + "navy": (20, 40, 80), + "deep_blue": (15, 25, 60), + "teal": (0, 128, 128), + "forest": (34, 85, 51), + "warm_white": (255, 250, 240), + "accent_blue": (60, 100, 170), +} + + +class Transition(Enum): + CUT = "cut" + FADE = "fade" + SLIDE_LEFT = "slide_left" + SLIDE_RIGHT = "slide_right" + ZOOM_IN = "zoom_in" + ZOOM_OUT = "zoom_out" + DISSOLVE = "dissolve" + WIPE_LEFT = "wipe_left" + KENBURNS = "kenburns" + + +class TextAnimation(Enum): + NONE = "none" + FADE_IN = "fade_in" + SLIDE_UP = "slide_up" + TYPEWRITER = "typewriter" + SCALE_IN = "scale_in" + + +@dataclass +class TextOverlay: + """A text element to render on a frame.""" + text: str + position: Tuple[int, int] # (x, y) — top-left of text block + font_path: str = FONT_SANS_BOLD + font_size: int = 48 + color: Tuple[int, int, int] = (255, 255, 255) + shadow: bool = True + shadow_color: Tuple[int, int, int] = (0, 0, 0) + shadow_offset: int = 3 + max_width: Optional[int] = None # wrap text if set + align: str = "left" # left, center, right + animation: TextAnimation = TextAnimation.FADE_IN + bg_color: Optional[Tuple[int, int, int, int]] = None # RGBA background pill + bg_padding: int = 20 + line_spacing: int = 8 + + +@dataclass +class LowerThird: + """Professional lower-third bar with headline + subtitle.""" + headline: str + subtitle: str = "" + bar_color: Tuple[int, int, int] = (20, 40, 80) + accent_color: Tuple[int, int, int] = (198, 168, 124) + text_color: Tuple[int, int, int] = (255, 255, 255) + position: str = "bottom" # bottom or top + width_pct: float = 0.65 + animation: TextAnimation = TextAnimation.SLIDE_UP + + +@dataclass +class Slide: + """One segment of the video.""" + duration: float # seconds + background_color: Tuple[int, int, int] = (0, 0, 0) + image_path: Optional[str] = None + image_fit: str = "cover" # cover, contain, fill + texts: List[TextOverlay] = field(default_factory=list) + lower_third: Optional[LowerThird] = None + transition_in: Transition = Transition.FADE + transition_duration: float = 0.5 # seconds for transition + kenburns_direction: str = "zoom_in" # zoom_in, zoom_out, pan_left, pan_right + overlay_color: Optional[Tuple[int, int, int, int]] = None # RGBA dark overlay + blur_background: bool = False + + +@dataclass +class VideoProject: + """Full video project definition.""" + slides: List[Slide] + width: int = 1920 + height: int = 1080 + fps: int = 30 + output_path: str = "output.mp4" + background_music: Optional[str] = None + music_volume: float = 0.3 + + +# ─── Frame Rendering ───────────────────────────────────────────────────────── + +def load_font(path: str, size: int) -> ImageFont.FreeTypeFont: + """Load a font, falling back to default if needed.""" + try: + return ImageFont.truetype(path, size) + except (IOError, OSError): + try: + return ImageFont.truetype(FONT_SANS, size) + except: + return ImageFont.load_default() + + +def fit_image(img: Image.Image, width: int, height: int, mode: str = "cover") -> Image.Image: + """Resize and crop/pad image to fit target dimensions.""" + if mode == "cover": + # Scale up to cover, then center-crop + ratio_w = width / img.width + ratio_h = height / img.height + ratio = max(ratio_w, ratio_h) + new_w = int(img.width * ratio) + new_h = int(img.height * ratio) + img = img.resize((new_w, new_h), Image.LANCZOS) + left = (new_w - width) // 2 + top = (new_h - height) // 2 + img = img.crop((left, top, left + width, top + height)) + elif mode == "contain": + img.thumbnail((width, height), Image.LANCZOS) + bg = Image.new("RGB", (width, height), (0, 0, 0)) + offset_x = (width - img.width) // 2 + offset_y = (height - img.height) // 2 + bg.paste(img, (offset_x, offset_y)) + img = bg + elif mode == "fill": + img = img.resize((width, height), Image.LANCZOS) + return img + + +def apply_kenburns(img: Image.Image, width: int, height: int, + progress: float, direction: str = "zoom_in") -> Image.Image: + """Apply Ken Burns (slow zoom/pan) effect to an image.""" + # Start with image slightly larger than frame + scale_start = 1.15 + scale_end = 1.0 + + if direction == "zoom_in": + scale_start, scale_end = 1.0, 1.15 + elif direction == "zoom_out": + scale_start, scale_end = 1.15, 1.0 + elif direction == "pan_left": + scale_start = scale_end = 1.15 + elif direction == "pan_right": + scale_start = scale_end = 1.15 + + # Smooth easing + t = ease_in_out(progress) + scale = scale_start + (scale_end - scale_start) * t + + scaled_w = int(width * scale) + scaled_h = int(height * scale) + img_scaled = img.resize((scaled_w, scaled_h), Image.LANCZOS) + + if direction == "pan_left": + x_offset = int((scaled_w - width) * (1 - t)) + y_offset = (scaled_h - height) // 2 + elif direction == "pan_right": + x_offset = int((scaled_w - width) * t) + y_offset = (scaled_h - height) // 2 + else: + x_offset = (scaled_w - width) // 2 + y_offset = (scaled_h - height) // 2 + + return img_scaled.crop((x_offset, y_offset, x_offset + width, y_offset + height)) + + +def ease_in_out(t: float) -> float: + """Smooth easing function (cubic).""" + if t < 0.5: + return 4 * t * t * t + else: + return 1 - pow(-2 * t + 2, 3) / 2 + + +def ease_out(t: float) -> float: + """Ease-out (decelerate).""" + return 1 - pow(1 - t, 3) + + +def render_text_on_frame(draw: ImageDraw.ImageDraw, frame: Image.Image, + text_overlay: TextOverlay, progress: float, + frame_width: int, frame_height: int): + """Render a text overlay with optional animation.""" + font = load_font(text_overlay.font_path, text_overlay.font_size) + + # Word wrap if max_width is set + if text_overlay.max_width: + lines = wrap_text(text_overlay.text, font, text_overlay.max_width) + else: + lines = text_overlay.text.split('\n') + + # Calculate animation state + anim_progress = min(1.0, progress * 3) # animate over first ~0.33s equivalent + alpha = 1.0 + y_offset = 0 + scale_factor = 1.0 + + if text_overlay.animation == TextAnimation.FADE_IN: + alpha = ease_out(anim_progress) + elif text_overlay.animation == TextAnimation.SLIDE_UP: + alpha = ease_out(anim_progress) + y_offset = int(50 * (1 - ease_out(anim_progress))) + elif text_overlay.animation == TextAnimation.SCALE_IN: + scale_factor = 0.5 + 0.5 * ease_out(anim_progress) + alpha = ease_out(anim_progress) + elif text_overlay.animation == TextAnimation.TYPEWRITER: + total_chars = sum(len(l) for l in lines) + visible_chars = int(total_chars * min(1.0, progress * 2)) + lines = _typewriter_lines(lines, visible_chars) + + if alpha < 0.01: + return + + # Calculate total text block size + line_heights = [] + line_widths = [] + for line in lines: + bbox = font.getbbox(line) if line else font.getbbox(" ") + w = bbox[2] - bbox[0] + h = bbox[3] - bbox[1] + line_widths.append(w) + line_heights.append(h) + + total_height = sum(line_heights) + text_overlay.line_spacing * (len(lines) - 1) + max_line_width = max(line_widths) if line_widths else 0 + + x, y = text_overlay.position + y += y_offset + + # Draw background pill if specified + if text_overlay.bg_color and alpha > 0.5: + pad = text_overlay.bg_padding + pill_width = text_overlay.max_width if text_overlay.max_width else max_line_width + bg_rect = [x - pad, y - pad, + x + pill_width + pad, y + total_height + pad] + bg_overlay = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + bg_draw = ImageDraw.Draw(bg_overlay) + bg_color_with_alpha = (*text_overlay.bg_color[:3], + int(text_overlay.bg_color[3] * alpha)) + bg_draw.rounded_rectangle(bg_rect, radius=12, fill=bg_color_with_alpha) + frame.paste(Image.alpha_composite( + frame.convert("RGBA"), bg_overlay).convert("RGB"), (0, 0)) + # Need new draw object after paste + draw = ImageDraw.Draw(frame) + + # Draw each line + current_y = y + for i, line in enumerate(lines): + if not line.strip(): + current_y += line_heights[i] + text_overlay.line_spacing + continue + + # Use max_width as the alignment container if set, otherwise use actual widest line + align_width = text_overlay.max_width if text_overlay.max_width else max_line_width + lx = x + if text_overlay.align == "center": + lx = x + (align_width - line_widths[i]) // 2 + elif text_overlay.align == "right": + lx = x + (align_width - line_widths[i]) + + # Shadow + if text_overlay.shadow and alpha > 0.3: + so = text_overlay.shadow_offset + shadow_color = (*text_overlay.shadow_color, int(180 * alpha)) + # Use a temporary RGBA layer for shadow + shadow_layer = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow_layer) + shadow_draw.text((lx + so, current_y + so), line, font=font, + fill=shadow_color) + frame.paste(Image.alpha_composite( + frame.convert("RGBA"), shadow_layer).convert("RGB"), (0, 0)) + draw = ImageDraw.Draw(frame) + + # Main text + if alpha >= 1.0: + draw.text((lx, current_y), line, font=font, fill=text_overlay.color) + else: + txt_layer = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + txt_draw = ImageDraw.Draw(txt_layer) + color_with_alpha = (*text_overlay.color, int(255 * alpha)) + txt_draw.text((lx, current_y), line, font=font, fill=color_with_alpha) + frame.paste(Image.alpha_composite( + frame.convert("RGBA"), txt_layer).convert("RGB"), (0, 0)) + draw = ImageDraw.Draw(frame) + + current_y += line_heights[i] + text_overlay.line_spacing + + return draw + + +def render_lower_third(frame: Image.Image, lt: LowerThird, + progress: float, width: int, height: int) -> Image.Image: + """Render a professional lower-third bar.""" + anim_progress = ease_out(min(1.0, progress * 3)) + + bar_width = int(width * lt.width_pct) + bar_height = 90 if lt.subtitle else 60 + accent_height = 4 + + # Slide in from left + x_offset = int(bar_width * (1 - anim_progress)) * -1 + y_pos = height - bar_height - 80 if lt.position == "bottom" else 80 + + overlay = Image.new("RGBA", frame.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + alpha = int(230 * anim_progress) + + # Main bar + bar_color_alpha = (*lt.bar_color, alpha) + draw.rectangle([x_offset, y_pos, x_offset + bar_width, y_pos + bar_height], + fill=bar_color_alpha) + + # Accent stripe on top + accent_color_alpha = (*lt.accent_color, alpha) + draw.rectangle([x_offset, y_pos, x_offset + bar_width, y_pos + accent_height], + fill=accent_color_alpha) + + # Headline + headline_font = load_font(FONT_SANS_BOLD, 32) + text_alpha = int(255 * anim_progress) + draw.text((x_offset + 30, y_pos + accent_height + 8), lt.headline, + font=headline_font, fill=(*lt.text_color, text_alpha)) + + # Subtitle + if lt.subtitle: + sub_font = load_font(FONT_SANS, 22) + draw.text((x_offset + 30, y_pos + accent_height + 46), lt.subtitle, + font=sub_font, fill=(*lt.accent_color, text_alpha)) + + return Image.alpha_composite(frame.convert("RGBA"), overlay).convert("RGB") + + +def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: + """Word-wrap text to fit within max_width pixels.""" + words = text.split() + lines = [] + current_line = [] + + for word in words: + test_line = " ".join(current_line + [word]) + bbox = font.getbbox(test_line) + if bbox[2] - bbox[0] <= max_width: + current_line.append(word) + else: + if current_line: + lines.append(" ".join(current_line)) + current_line = [word] + + if current_line: + lines.append(" ".join(current_line)) + + return lines if lines else [text] + + +def _typewriter_lines(lines: List[str], visible_chars: int) -> List[str]: + """Return lines with only the first N characters visible.""" + result = [] + remaining = visible_chars + for line in lines: + if remaining <= 0: + break + if remaining >= len(line): + result.append(line) + remaining -= len(line) + else: + result.append(line[:remaining]) + remaining = 0 + return result + + +# ─── Transition Rendering ──────────────────────────────────────────────────── + +def render_transition(frame_a: Image.Image, frame_b: Image.Image, + progress: float, transition: Transition) -> Image.Image: + """Blend two frames according to the transition type.""" + t = ease_in_out(progress) + w, h = frame_a.size + + if transition == Transition.CUT: + return frame_b if progress > 0.5 else frame_a + + elif transition == Transition.FADE or transition == Transition.DISSOLVE: + return Image.blend(frame_a, frame_b, t) + + elif transition == Transition.SLIDE_LEFT: + offset = int(w * t) + result = Image.new("RGB", (w, h)) + result.paste(frame_a, (-offset, 0)) + result.paste(frame_b, (w - offset, 0)) + return result + + elif transition == Transition.SLIDE_RIGHT: + offset = int(w * t) + result = Image.new("RGB", (w, h)) + result.paste(frame_a, (offset, 0)) + result.paste(frame_b, (-(w - offset), 0)) + return result + + elif transition == Transition.ZOOM_IN: + scale = 1.0 + 0.3 * t + scaled = frame_a.resize((int(w * scale), int(h * scale)), Image.LANCZOS) + cx = (scaled.width - w) // 2 + cy = (scaled.height - h) // 2 + cropped = scaled.crop((cx, cy, cx + w, cy + h)) + return Image.blend(cropped, frame_b, t) + + elif transition == Transition.WIPE_LEFT: + result = frame_a.copy() + wipe_pos = int(w * t) + result.paste(frame_b.crop((0, 0, wipe_pos, h)), (0, 0)) + return result + + else: + return Image.blend(frame_a, frame_b, t) + + +# ─── Slide Frame Generation ────────────────────────────────────────────────── + +def render_slide_frame(slide: Slide, frame_num: int, total_frames: int, + width: int, height: int) -> Image.Image: + """Render a single frame of a slide (no transitions — just the slide content).""" + progress = frame_num / max(total_frames - 1, 1) + + # Base frame + frame = Image.new("RGB", (width, height), slide.background_color) + + # Background image + if slide.image_path and os.path.exists(slide.image_path): + try: + img = Image.open(slide.image_path).convert("RGB") + if slide.transition_in == Transition.KENBURNS: + img = fit_image(img, int(width * 1.2), int(height * 1.2), "cover") + frame = apply_kenburns(img, width, height, progress, + slide.kenburns_direction) + else: + frame = fit_image(img, width, height, slide.image_fit) + except Exception as e: + print(f"Warning: Could not load image {slide.image_path}: {e}") + + # Blur background + if slide.blur_background: + frame = frame.filter(ImageFilter.GaussianBlur(radius=15)) + + # Dark overlay + if slide.overlay_color: + overlay = Image.new("RGBA", (width, height), slide.overlay_color) + frame = Image.alpha_composite(frame.convert("RGBA"), overlay).convert("RGB") + + # Text overlays + draw = ImageDraw.Draw(frame) + for text_overlay in slide.texts: + draw = render_text_on_frame(draw, frame, text_overlay, progress, width, height) + + # Lower third + if slide.lower_third: + frame = render_lower_third(frame, slide.lower_third, progress, width, height) + + return frame + + +# ─── Video Assembly ────────────────────────────────────────────────────────── + +def render_video(project: VideoProject, progress_callback=None) -> str: + """ + Render a complete video project to MP4. + Returns the output file path. + """ + width, height, fps = project.width, project.height, project.fps + + with tempfile.TemporaryDirectory() as tmpdir: + frame_dir = os.path.join(tmpdir, "frames") + os.makedirs(frame_dir) + + global_frame = 0 + total_frames_est = sum(int(s.duration * fps) for s in project.slides) + + # Pre-render all slide frames + slide_frames_cache = {} # slide_index -> {frame_num: Image} + + for slide_idx, slide in enumerate(project.slides): + slide_total_frames = int(slide.duration * fps) + + for f in range(slide_total_frames): + frame = render_slide_frame(slide, f, slide_total_frames, width, height) + + # Handle transitions between slides + if slide_idx > 0 and f < int(slide.transition_duration * fps): + prev_slide = project.slides[slide_idx - 1] + prev_total = int(prev_slide.duration * fps) + prev_frame = render_slide_frame(prev_slide, prev_total - 1, + prev_total, width, height) + t_progress = f / max(int(slide.transition_duration * fps) - 1, 1) + frame = render_transition(prev_frame, frame, t_progress, + slide.transition_in) + + # Save frame + frame_path = os.path.join(frame_dir, f"frame_{global_frame:06d}.png") + frame.save(frame_path, "PNG") + global_frame += 1 + + if progress_callback and global_frame % 10 == 0: + progress_callback(global_frame, total_frames_est) + + print(f"Rendered {global_frame} frames. Encoding video...") + + # Encode with ffmpeg + output_path = project.output_path + ffmpeg_cmd = [ + "ffmpeg", "-y", + "-framerate", str(fps), + "-i", os.path.join(frame_dir, "frame_%06d.png"), + ] + + # Add background music if provided + if project.background_music and os.path.exists(project.background_music): + ffmpeg_cmd.extend([ + "-i", project.background_music, + "-filter_complex", + f"[1:a]volume={project.music_volume}[a]", + "-map", "0:v", "-map", "[a]", + "-shortest", + ]) + + ffmpeg_cmd.extend([ + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-preset", "medium", + "-crf", "23", + "-movflags", "+faststart", + output_path + ]) + + result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"ffmpeg error: {result.stderr}") + raise RuntimeError(f"ffmpeg encoding failed: {result.stderr[-500:]}") + + print(f"Video saved to {output_path}") + return output_path + + +# ─── Convenience Builders ──────────────────────────────────────────────────── + +def create_title_slide(headline: str, subtitle: str = "", + bg_color=(20, 40, 80), accent_color=(198, 168, 124), + duration: float = 4.0, width: int = 1920, + height: int = 1080, image_path: str = None) -> Slide: + """Create a professional title slide.""" + texts = [] + + # Headline — centered + headline_font_size = 72 if len(headline) < 30 else 56 + headline_y = height // 2 - 80 + texts.append(TextOverlay( + text=headline, + position=(width // 2 - 400, headline_y), + font_path=FONT_SANS_BOLD, + font_size=headline_font_size, + color=(255, 255, 255), + max_width=800, + align="center", + animation=TextAnimation.FADE_IN, + shadow=True, + )) + + # Accent line + if subtitle: + texts.append(TextOverlay( + text=subtitle, + position=(width // 2 - 400, headline_y + 100), + font_path=FONT_SANS, + font_size=32, + color=accent_color, + max_width=800, + align="center", + animation=TextAnimation.SLIDE_UP, + shadow=True, + )) + + return Slide( + duration=duration, + background_color=bg_color, + image_path=image_path, + overlay_color=(0, 0, 0, 140) if image_path else None, + texts=texts, + transition_in=Transition.FADE, + transition_duration=0.8, + ) + + +def create_photo_slide(image_path: str, caption: str = "", + duration: float = 4.0, width: int = 1920, + height: int = 1080, + kenburns: bool = True) -> Slide: + """Create a photo slide with optional caption and Ken Burns effect.""" + texts = [] + if caption: + texts.append(TextOverlay( + text=caption, + position=(60, height - 140), + font_path=FONT_SANS_BOLD, + font_size=36, + color=(255, 255, 255), + max_width=width - 120, + animation=TextAnimation.SLIDE_UP, + shadow=True, + bg_color=(0, 0, 0, 160), + bg_padding=16, + )) + + return Slide( + duration=duration, + image_path=image_path, + texts=texts, + transition_in=Transition.KENBURNS if kenburns else Transition.FADE, + transition_duration=0.6, + kenburns_direction="zoom_in", + ) + + +def create_text_slide(headline: str, bullets: List[str] = None, + bg_color=(245, 245, 245), text_color=(30, 30, 30), + duration: float = 5.0, width: int = 1920, + height: int = 1080) -> Slide: + """Create a text/content slide with headline and optional bullets.""" + texts = [ + TextOverlay( + text=headline, + position=(120, 120), + font_path=FONT_SANS_BOLD, + font_size=52, + color=text_color, + max_width=width - 240, + animation=TextAnimation.FADE_IN, + shadow=False, + ) + ] + + if bullets: + bullet_text = "\n".join(f"• {b}" for b in bullets) + texts.append(TextOverlay( + text=bullet_text, + position=(140, 220), + font_path=FONT_SANS, + font_size=36, + color=(80, 80, 80), + max_width=width - 280, + animation=TextAnimation.SLIDE_UP, + shadow=False, + line_spacing=20, + )) + + return Slide( + duration=duration, + background_color=bg_color, + texts=texts, + transition_in=Transition.FADE, + transition_duration=0.5, + ) + + +def create_cta_slide(headline: str, subtitle: str = "", + contact_info: str = "", + bg_color=(20, 40, 80), + accent_color=(198, 168, 124), + duration: float = 5.0, width: int = 1920, + height: int = 1080) -> Slide: + """Create a call-to-action / closing slide.""" + texts = [ + TextOverlay( + text=headline, + position=(width // 2 - 400, height // 2 - 100), + font_path=FONT_SANS_BOLD, + font_size=64, + color=(255, 255, 255), + max_width=800, + align="center", + animation=TextAnimation.SCALE_IN, + shadow=True, + ) + ] + + if subtitle: + texts.append(TextOverlay( + text=subtitle, + position=(width // 2 - 400, height // 2 + 20), + font_path=FONT_SANS, + font_size=32, + color=accent_color, + max_width=800, + align="center", + animation=TextAnimation.FADE_IN, + )) + + if contact_info: + texts.append(TextOverlay( + text=contact_info, + position=(width // 2 - 400, height // 2 + 80), + font_path=FONT_SANS, + font_size=28, + color=(200, 200, 200), + max_width=800, + align="center", + animation=TextAnimation.FADE_IN, + )) + + return Slide( + duration=duration, + background_color=bg_color, + texts=texts, + transition_in=Transition.FADE, + transition_duration=1.0, + ) + + +# ─── Entry Point for Testing ──────────────────────────────────────────────── + +if __name__ == "__main__": + print("Video Creator Engine loaded successfully.") + print(f"Available fonts: {FONT_SANS}, {FONT_SANS_BOLD}") + print(f"Pillow version: {Image.__version__}") + + # Quick smoke test — render one frame + test_slide = create_title_slide("Test Video", "Engine Check") + frame = render_slide_frame(test_slide, 15, 30, 1920, 1080) + test_path = "/tmp/video_engine_test.png" + frame.save(test_path) + print(f"Test frame saved to {test_path}") diff --git a/skills/video-to-obsidian/SKILL.md b/skills/video-to-obsidian/SKILL.md new file mode 100644 index 0000000..f1c4651 --- /dev/null +++ b/skills/video-to-obsidian/SKILL.md @@ -0,0 +1,89 @@ +--- +name: video-to-obsidian +description: "Universal video-to-Obsidian logger for Graeham Watts. Takes ANY video URL — Instagram Reel, YouTube video, YouTube Short, TikTok, Vimeo, anything yt-dlp supports — transcribes it, auto-categorizes it, and writes a structured markdown note to the Obsidian vault at Documents/Obsidian/Instagram Saves/ with full frontmatter. The source URL is ALWAYS preserved as a required field — non-negotiable, because video is a visual medium and the user needs to click back to see the actual treatment (cuts, captions, on-screen text). Use this skill ANY time the user wants to save a video reference into their swipe-file vault, log a competitor's video for later study, archive an inspirational reel/short, add a video to their content library, build out their hook library, or 'put this in Obsidian.' Also called by instagram-competitor-scraper when scrape results need to be persisted, and by content-creation-engine when source videos identified during ideation should be archived. Triggers include: 'log this video to Obsidian', 'save this reel to my vault', 'add this YouTube short to Obsidian', 'put this in my swipe file', 'archive this reel', 'add to hook library', or just pasting a video URL with 'save it' / 'log it' context." +--- + +# Video to Obsidian + +> **One job:** take a video URL, transcribe it, categorize it, and write a clean markdown note to the right folder in `Documents/Obsidian/Instagram Saves/`. Source URL always preserved. + +## Why this exists + +You're building a content intelligence layer. Words from videos need to live as searchable, queryable, AI-readable data in one place — Obsidian. But video is a visual medium, so the URL has to come along for the ride. Without the URL, the note is dead. + +This skill is the **destination layer** for everything visual you save. Manual ad-hoc saves, scraper output, even YouTube videos you watch for research — all flow through here and land in the same schema. + +## What it accepts + +Any URL supported by `yt-dlp` (1,800+ platforms). Most common: Instagram Reels/posts, YouTube videos/Shorts, TikTok, Vimeo, Twitter/X video, Facebook video, LinkedIn video, direct video file URLs. + +## How to invoke + +``` +log this to Obsidian: https://www.instagram.com/reels/DYSUqcsuGX9/ +save this short to my vault: https://youtube.com/shorts/abc123 +add to hook library: https://tiktok.com/@user/video/789 +``` + +CLI form: + +```bash +python3 scripts/log_to_vault.py "https://www.instagram.com/reels/ABC/" +python3 scripts/log_to_vault.py "URL" --folder "Hook Library" --my-use steal-hook +python3 scripts/log_to_vault.py "URL" --metadata-json '{"engagement":{"views":12000}}' +python3 scripts/log_to_vault.py "URL" --transcript-text "already have it" # skip transcription +``` + +## What it does + +1. **Validates URL** — must be present +2. **Calls `video-transcriber`** (or uses pre-supplied --transcript-text) +3. **Pulls metadata** via yt-dlp (title, duration, creator, post date) +4. **Merges extra metadata** from --metadata-json (engagement stats from scraper) +5. **Auto-categorizes** into right folder via heuristics +6. **Auto-tags** content_type + hook_pattern + topic_tags +7. **Writes the note** to `Obsidian/Instagram Saves//-.md` + +## Vault path + +Auto-detected from `C:\Users\Graeham Watts\Documents\Obsidian\Instagram Saves\` or `/sessions/.../mnt/Obsidian/Instagram Saves/` (sandbox). Override with `--vault-root`. + +## Folder routing + +| Folder | Trigger | +|---|---| +| `AI & Tech Tutorials/` | AI/Claude/MCP/automation keywords in transcript or caption | +| `Real Estate Content/` | Real estate / Bay Area / Peninsula keywords | +| `How-To Videos/` | 'how to' / 'step 1' / 'tutorial' in opening | +| `Hook Library/` | --my-use steal-hook OR --folder Hook Library | +| `Examples to Clone/` | --my-use full-clone | +| `Style References/` | --my-use style-ref | +| `_Inbox/` | Default fallback | + +Categorization is a starting guess. Review weekly, move if needed. + +## Frontmatter schema + +Every note: url (required), source, creator, creator_followers, post_type, post_date, saved_date, duration_sec, content_type[], hook_pattern[], topic_tags[], my_use[], saved_for[], engagement{views,likes,comments,saves,engagement_rate}, status, transcript_available, discovered_via. + +## Auto-tagging + +**content_type:** how-to (keyword match), ai-workflow, talking-head (short + first-person), walkthrough (sequence words), comparison (vs/versus), list (top N pattern). + +**hook_pattern:** pattern-interrupt (negation at start), contrarian ('most people'/'the truth'), curiosity-gap ('the secret'/'nobody talks'), question-hook (ends with ?), direct-promise ('here's how'). + +**topic_tags:** extracted from caption hashtags + transcript keyword map. + +## Idempotency + +If URL exists in vault, default = skip with stderr message. `--update` updates engagement only. `--force` overwrites. + +## Integration + +- `instagram-competitor-scraper` pipes results via `--metadata-json` +- `content-creation-engine` archives source videos identified during ideation +- `cinematic-hooks` reads the Hook Library folder this populates + +## Why URL preservation is non-negotiable + +Video is a visual medium. Transcripts lose cuts, on-screen text, visual style, energy, sound design. Without the URL, the note is a partial copy. With the URL, the note is a queryable launchpad back to the original. This is the one thing the skill must never get wrong. diff --git a/skills/video-to-obsidian/scripts/log_to_vault.py b/skills/video-to-obsidian/scripts/log_to_vault.py new file mode 100644 index 0000000..17ebda3 --- /dev/null +++ b/skills/video-to-obsidian/scripts/log_to_vault.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +log_to_vault.py — Video URL -> Obsidian vault note for Graeham Watts. + +Takes ANY video URL (Instagram, YouTube, Shorts, TikTok, etc.), transcribes it +via video-transcriber, auto-categorizes it, and writes a clean markdown note to +the Obsidian vault at Documents/Obsidian/Instagram Saves/. + +The source URL is ALWAYS preserved in frontmatter. Non-negotiable. + +Usage: + python3 log_to_vault.py "https://www.instagram.com/reels/ABC/" + python3 log_to_vault.py "URL" --folder "Hook Library" --my-use steal-hook + python3 log_to_vault.py "URL" --metadata-json '{"engagement":{"views":12000}}' + python3 log_to_vault.py "URL" --update # update engagement stats only + python3 log_to_vault.py "URL" --force # overwrite even if exists + python3 log_to_vault.py "URL" --dry-run # show what would be written + python3 log_to_vault.py "URL" --transcript-text "..." # skip transcription +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def find_vault_root(): + candidates = [ + Path("/sessions/gifted-elegant-ritchie/mnt/Obsidian/Instagram Saves"), + Path(r"C:\Users\Graeham Watts\Documents\Obsidian\Instagram Saves"), + Path.home() / "Documents" / "Obsidian" / "Instagram Saves", + ] + for c in candidates: + if c.exists(): + return c + return candidates[0] + + +VAULT_ROOT = find_vault_root() +SKILLS_ROOT = Path(__file__).resolve().parents[3] +TRANSCRIBER_SCRIPT = SKILLS_ROOT / "skills" / "video-transcriber" / "scripts" / "transcribe.py" + +FOLDERS = { + "ai": "AI & Tech Tutorials", + "re": "Real Estate Content", + "howto": "How-To Videos", + "hook": "Hook Library", + "clone": "Examples to Clone", + "style": "Style References", + "inbox": "_Inbox", + "misc": "Misc", +} + + +def detect_source(url): + u = url.lower() + if "instagram.com" in u: return "instagram" + if "youtube.com" in u or "youtu.be" in u: return "youtube" + if "tiktok.com" in u: return "tiktok" + if "vimeo.com" in u: return "vimeo" + if "twitter.com" in u or "x.com" in u: return "twitter" + if "facebook.com" in u or "fb.watch" in u: return "facebook" + if "linkedin.com" in u: return "linkedin" + return "other" + + +def detect_post_type(url, source, duration_sec=0): + u = url.lower() + if source == "instagram": + if "/reel" in u: return "reel" + if "/p/" in u: return "carousel" if duration_sec == 0 else "reel" + return "post" + if source == "youtube": + if "/shorts/" in u: return "short" + return "video" + return "video" + + +def transcribe_url(url): + if not TRANSCRIBER_SCRIPT.exists(): + return {"error": f"video-transcriber not found at {TRANSCRIBER_SCRIPT}"} + cmd = [sys.executable, str(TRANSCRIBER_SCRIPT), url, "--json"] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + if result.returncode != 0: + return {"error": result.stderr[:500]} + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return {"error": "Transcriber returned non-JSON output"} + + +AI_KEYWORDS = ["ai", "claude", "chatgpt", "gpt", "openai", "anthropic", "llm", + "n8n", "mcp", "automation", "codex", "prompt", "agent", + "machine learning", "neural"] +RE_KEYWORDS = ["real estate", "realtor", "listing", "mls", "home buying", + "home buyer", "mortgage", "bay area", "peninsula", "east palo alto", + "redwood city", "menlo park", "palo alto", "san mateo", + "open house", "broker", "escrow"] + + +def folder_for(transcript_text, caption, source, explicit_folder, my_use): + if explicit_folder: return explicit_folder + if "hook-only" in my_use or "steal-hook" in my_use: return FOLDERS["hook"] + if "full-clone" in my_use: return FOLDERS["clone"] + if "style-ref" in my_use: return FOLDERS["style"] + blob = (transcript_text + " " + caption).lower() + if any(k in blob for k in AI_KEYWORDS): return FOLDERS["ai"] + if any(k in blob for k in RE_KEYWORDS): return FOLDERS["re"] + first_line = transcript_text.strip().split(".")[0].lower() if transcript_text else "" + if any(s in first_line for s in ["how to", "here's how", "step 1", "tutorial", "the way to"]): + return FOLDERS["howto"] + return FOLDERS["inbox"] + + +def auto_content_types(transcript, caption, duration_sec): + tags = [] + blob = (transcript + " " + caption).lower() + first_line = transcript.strip().split(".")[0].lower() if transcript else "" + if any(s in first_line for s in ["how to", "here's how", "step 1", "tutorial"]): tags.append("how-to") + if any(k in blob for k in AI_KEYWORDS): tags.append("ai-workflow") + if duration_sec and duration_sec < 90 and re.search(r"\b(i|my|let me|i'm|i'll)\b", first_line): + tags.append("talking-head") + if sum(blob.count(w) for w in ["first", "then", "next", "finally"]) >= 3: tags.append("walkthrough") + if any(k in blob for k in [" vs ", " versus ", "compared to", "better than"]): tags.append("comparison") + if re.search(r"\btop \d+\b|\b\d+ ways\b|\b\d+ tips\b|\bbest \d+\b", blob): tags.append("list") + return sorted(set(tags)) + + +def auto_hook_patterns(transcript): + if not transcript: return [] + first = transcript.strip().split(".")[0].lower() + tags = [] + if re.match(r"^(stop|don't|never|wrong|nobody|forget)\b", first): tags.append("pattern-interrupt") + if any(p in first for p in ["most people think", "everyone says", "the truth is", + "might be the most pointless", "this is wrong"]): + tags.append("contrarian") + if any(p in first for p in ["you won't believe", "the secret", "nobody talks about", + "what they don't tell you", "the truth about"]): + tags.append("curiosity-gap") + if first.endswith("?"): tags.append("question-hook") + if re.match(r"^here'?s how\b", first): tags.append("direct-promise") + return sorted(set(tags)) + + +def auto_topic_tags(caption, transcript): + tags = set() + for m in re.findall(r"#(\w+)", caption or ""): tags.add(m.lower()) + blob = (transcript or "").lower() + keyword_map = { + "ai": ["ai", "artificial intelligence"], + "claude-code": ["claude code"], + "real-estate": ["real estate", "realtor"], + "bay-area": ["bay area", "peninsula"], + "content": ["content", "post", "reel"], + "automation": ["automation", "automate", "workflow"], + "notion": ["notion"], + "obsidian": ["obsidian"], + } + for tag, keywords in keyword_map.items(): + if any(k in blob for k in keywords): tags.add(tag) + return sorted(tags) + + +def slugify(text, max_len=60): + s = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") + return s[:max_len] or "untitled" + + +def render_note(data): + fm = { + "url": data["url"], "source": data["source"], + "creator": data.get("creator") or "@unknown", + "creator_followers": data.get("creator_followers", 0), + "post_type": data["post_type"], "post_date": data.get("post_date") or "", + "saved_date": data["saved_date"], "duration_sec": data.get("duration_sec", 0), + "content_type": data.get("content_type", []), + "hook_pattern": data.get("hook_pattern", []), + "topic_tags": data.get("topic_tags", []), + "my_use": data.get("my_use", ["reference-only"]), + "saved_for": data.get("saved_for", []), + "engagement": data.get("engagement", {"views": 0, "likes": 0, "comments": 0, "saves": None, "engagement_rate": 0.0}), + "status": data.get("status", "unprocessed"), + "transcript_available": data.get("transcript_available", True), + "discovered_via": data.get("discovered_via", "manual"), + } + + def yaml_value(v): + if isinstance(v, list): + return "[" + ", ".join(yaml_value(x) for x in v) + "]" + if isinstance(v, dict): + return "\n " + "\n ".join(f"{k}: {yaml_value(val)}" for k, val in v.items()) + if isinstance(v, str): + return f'"{v}"' if any(c in v for c in ": #@&*?|>%{}[],") else v + if v is None: return "null" + return str(v) + + fm_lines = ["---"] + for k, v in fm.items(): + fm_lines.append(f"{k}: {yaml_value(v)}") + fm_lines.append("---") + fm_block = "\n".join(fm_lines) + + transcript = data.get("transcript", "") + first_sentence = transcript.strip().split(".")[0].strip() + "." if transcript else "" + title = data.get("title") or f"{fm['creator']} — {fm['post_date'] or 'undated'}" + platform_name = data["source"].title() + + body = f""" + +# {title} + +## Hook (first 3 seconds) + +> {first_sentence} + +## Why saved + +{data.get('why_saved') or f"Logged via {fm['discovered_via']}"} + +## Transcript + +{transcript if transcript else '_(Transcription failed — see source URL.)_'} + +## Visual notes + +_(Add your notes about cuts, captions, on-screen text, style — transcripts don't capture this.)_ + +## Action + +- [ ] Use this for: ____ +- [ ] Pair with skill: ____ +- [ ] Output target: ____ + +--- + +**Source:** [Watch on {platform_name}]({fm['url']}) +""" + return fm_block + body + + +def find_existing_note(vault_root, url): + if not vault_root.exists(): return None + for p in vault_root.rglob("*.md"): + try: + content = p.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + m = re.search(r"^url:\s*(\S+)", content, re.MULTILINE) + if m and m.group(1).strip().strip('"') == url: + return p + return None + + +def main(): + parser = argparse.ArgumentParser(description="Log a video URL to Obsidian vault") + parser.add_argument("url") + parser.add_argument("--folder", default=None) + parser.add_argument("--my-use", default="reference-only") + parser.add_argument("--saved-for", default="") + parser.add_argument("--why", default="") + parser.add_argument("--metadata-json", default="") + parser.add_argument("--update", action="store_true") + parser.add_argument("--force", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--vault-root", default=str(VAULT_ROOT)) + parser.add_argument("--transcript-text", default=None) + args = parser.parse_args() + + if not args.url: + print("ERROR: URL is required", file=sys.stderr); sys.exit(1) + + vault_root = Path(args.vault_root) + existing = find_existing_note(vault_root, args.url) + if existing and not (args.update or args.force or args.dry_run): + print(f"[skip] URL already in vault: {existing}", file=sys.stderr) + print(str(existing)); return + + extra = {} + if args.metadata_json: + try: extra = json.loads(args.metadata_json) + except json.JSONDecodeError as e: + print(f"ERROR: --metadata-json is not valid JSON: {e}", file=sys.stderr); sys.exit(1) + + if args.transcript_text: + print(f"[transcribe] using pre-supplied transcript ({len(args.transcript_text)} chars)", file=sys.stderr) + t = {"transcript_plain": args.transcript_text} + else: + print(f"[transcribe] {args.url}", file=sys.stderr) + t = transcribe_url(args.url) + + transcript_text = t.get("transcript_plain", "") if "error" not in t else "" + title = t.get("title") or extra.get("title") + duration = t.get("duration_sec") or extra.get("duration_sec") or 0 + creator = extra.get("creator") or (t.get("uploader") and f"@{t['uploader']}") or "@unknown" + post_date = extra.get("post_date") or (t.get("upload_date") and f"{t['upload_date'][:4]}-{t['upload_date'][4:6]}-{t['upload_date'][6:8]}") + caption = extra.get("caption", "") + + source = detect_source(args.url) + post_type = detect_post_type(args.url, source, duration) + my_use = [s.strip() for s in args.my_use.split(",") if s.strip()] + saved_for = [s.strip() for s in args.saved_for.split(",") if s.strip()] + + content_type = auto_content_types(transcript_text, caption, duration) + hook_pattern = auto_hook_patterns(transcript_text) + topic_tags = auto_topic_tags(caption, transcript_text) + + folder_name = folder_for(transcript_text, caption, source, args.folder, my_use) + target_dir = vault_root / folder_name + target_dir.mkdir(parents=True, exist_ok=True) + + data = { + "url": args.url, "source": source, "creator": creator, + "creator_followers": extra.get("creator_followers", 0), + "post_type": post_type, "post_date": post_date, + "saved_date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), + "duration_sec": duration, "content_type": content_type, + "hook_pattern": hook_pattern, "topic_tags": topic_tags, + "my_use": my_use, "saved_for": saved_for, + "engagement": extra.get("engagement", {"views": 0, "likes": 0, "comments": 0, "saves": None, "engagement_rate": 0.0}), + "status": "unprocessed", "transcript_available": bool(transcript_text), + "discovered_via": extra.get("discovered_via", "manual"), + "transcript": transcript_text, "title": title, "why_saved": args.why, + } + + date_part = data["saved_date"] + slug_part = slugify(title or creator.lstrip("@") or "video") + filename = f"{date_part}-{slug_part}.md" + + target_path = existing if (existing and args.update) else (target_dir / filename) + note = render_note(data) + + if args.dry_run: + print(f"[dry-run] Would write to: {target_path}") + print(note); return + + target_path.write_text(note, encoding="utf-8") + print(f"[written] {target_path}", file=sys.stderr) + print(str(target_path)) + + +if __name__ == "__main__": + main() diff --git a/skills/video-transcriber/SKILL.md b/skills/video-transcriber/SKILL.md new file mode 100644 index 0000000..48a747d --- /dev/null +++ b/skills/video-transcriber/SKILL.md @@ -0,0 +1,244 @@ +--- +name: video-transcriber +description: "Universal video-to-text transcriber for Graeham's team (Peter, Ellie, John, Adrian). Hand it any video — paste a URL (YouTube, Facebook, Instagram, TikTok, Vimeo, Twitter/X, Reddit, LinkedIn) OR upload/point to a local video file (.mp4, .mov, .m4a, .mp3, .wav) — and get a clean transcript back. Auto-detects whether the input is a URL or a local file, then picks the cheapest working backend: free caption pull first, then local faster-whisper on Graeham's Windows machine for everything else. Trigger on: transcribe, transcript, get the transcript, video to text, captions, subtitles, what does this video say, YouTube/Reel/Short/TikTok transcript, transcribe this file, transcribe this video I uploaded. Also triggers when user just pastes a video URL or uploads a video file with no other context. Pairs with video-watcher (visual analysis) when full A+V breakdown needed." +--- + +# Video Transcriber + +> **One job, one skill.** Hand it a video — by URL or by file — get the transcript back. That's it. + +This skill exists because Peter, Ellie, John, and Adrian shouldn't have to remember which Python script lives where or which Apify actor handles which platform. They drop in a URL or a file, and the right thing happens automatically. + +## Who uses this and how + +**Peter and Ellie** (video editors): use this to pull transcripts of: +- Competitor videos they're studying for shot ideas +- Reference videos Graeham sends them as "make ours like this" +- Long-form interviews where they need to find specific quotes for cuts +- Client testimonial videos that need to be transcribed for captions +- Local screen recordings or Zoom exports sitting in Downloads + +**John** (Blog Track): use this to: +- Convert Graeham's recorded YouTube videos into blog-post source material +- Pull transcripts of industry videos and webinars for cite-ready statistics + +**Adrian** (Client Care): use this to: +- Transcribe client video messages for record-keeping +- Convert market-update videos into text summaries for clients + +**Graeham**: invoke directly when prepping content or reviewing reference material. Frequently uploads webinar or Zoom recordings he's just attended. + +## How to invoke + +Any of these work: + +1. **Just paste the URL**, nothing else: + ``` + https://www.youtube.com/watch?v=PYMsmSx8Tyw + ``` + +2. **Paste the URL with a verb**: + ``` + transcribe this: https://www.facebook.com/.../videos/123456789 + ``` + +3. **Upload a local video file** in Cowork or paste a local path: + ``` + transcribe this video + [uploaded file: webinar-recording.mp4] + ``` + ``` + transcribe C:\Users\Graeham Watts\Downloads\zoom-call.mp4 + ``` + +4. **Multiple inputs at once** (processed in turn — URLs and files can be mixed): + ``` + transcribe all three: + https://www.youtube.com/watch?v=AAA + C:\path\to\local.mp4 + https://www.instagram.com/reel/BBB/ + ``` + +The skill auto-detects URL vs local path and routes to the right backend. + +## Decision tree — when to use which path + +``` +INPUT +│ +├── Local file path (or uploaded file) ──────────────→ PATH B (Windows local faster-whisper) +│ +└── URL + ├── YouTube or other platform with captions + │ └── Try PATH A (caption pull) first + │ ├── Captions exist → return transcript ✓ + │ └── No captions → fall through to PATH B + │ + └── Any other URL (Instagram, TikTok, Facebook, etc.) + └── PATH B (Windows downloads via yt-dlp, then faster-whisper) +``` + +## PATH A — Caption pull (free, instant, ~1–3 sec) + +Runs in the Cowork sandbox. Works for any URL where the platform exposes captions (almost all YouTube videos, some Vimeo, some others). + +```bash +python3 scripts/transcribe.py "" --prefer-captions +``` + +Returns in ~1–3 seconds. Costs $0. This is always the first try for URL inputs. + +## PATH B — Windows local faster-whisper (free, ~5–15 min for an hour-long video) + +This is the workhorse for everything caption pull can't handle, and the only path for local files. + +**Why local Windows, not sandbox:** The Cowork sandbox only has ~1.4 GB free disk. Installing openai-whisper or even faster-whisper requires multiple GB of dependencies (PyTorch, CUDA libs). We tried — it doesn't fit. Graeham's Windows machine has faster-whisper installed locally and ffmpeg available, so all real transcription work runs there. + +### How Claude drives PATH B + +Claude writes the right command for the situation, then asks the user to paste it into PowerShell. We can't drive the terminal directly because Windows Terminal is granted at tier "click" (visible + clickable, but typing is blocked by security policy). + +The script is at `scripts/transcribe_windows.py` and takes either a file path or a URL as its single argument: + +```powershell +python "\scripts\transcribe_windows.py" "C:\path\to\video.mp4" +python "\scripts\transcribe_windows.py" "https://www.youtube.com/watch?v=..." +python "\scripts\transcribe_windows.py" "C:\path\to\video.mp4" --model small.en --timestamps +``` + +The script: +- Detects URL vs local file +- For URLs: downloads audio via yt-dlp first (locally, fast) +- Loads faster-whisper with int8 quantization (CPU) +- Streams progress every ~20 segments so the user knows it's working +- Writes `{slug}_transcript.txt` and (if `--timestamps`) `{slug}_transcript_timestamped.txt` next to the source video (or to `--output-dir` if specified) + +### Model size guidance + +| Model | Speed (1hr audio, CPU) | Best for | +|---|---|---| +| `base.en` (default) | ~5–15 min | Default — fast, good enough for most speech | +| `small.en` | ~15–30 min | Better proper-noun accuracy; webinars with jargon | +| `medium.en` | ~30–60 min | Reference-quality; client testimonials going to print | + +Tell the user the tradeoff if accuracy matters more than time. Don't silently bump the model — they're waiting on the result. + +## Output format + +By default, returns clean prose: + +``` +Transcript: webinar-recording.mp4 +Duration: 1:08:26 +Language: en +Model: faster-whisper base.en (int8) +====================================================================== + +The spring housing market was supposed to be the big comeback season for 2026. Instead, a lot of markets are slowing down fast. Earlier this year, most people expected mortgage rates to ease... +``` + +If the user asks for timestamps: + +``` +[0:00:00] The spring housing market was supposed to be the big comeback +[0:00:08] Instead, a lot of markets are slowing down fast. +[0:00:13] Earlier this year, most people expected mortgage rates to ease +... +``` + +## Optional flags the user can request (spoken-language) + +- **"with timestamps"** — include `[MM:SS]` markers per segment +- **"better accuracy"** / **"use a bigger model"** → bump to `small.en` or `medium.en` +- **"summarize after"** — after producing the transcript, Claude generates a 3-bullet summary +- **"save to my Documents"** / **"save next to the video"** — choose output location + +## Workflow integration + +This skill is standalone. It does not require any other skill to function. + +Common follow-ons the user may request after a transcript: + +- **"and turn it into a blog post"** → hand transcript to `content-creation-engine` +- **"and find the best 30-second clip"** → scan for the highest-impact segment for a Short/Reel cutdown +- **"and pull cite-ready stats"** → scan for date-anchored numerical claims for AEO blog content +- **"and watch it too"** → fire `video-watcher` in parallel for full A+V breakdown + +## Failure handling + +| Failure | What the skill does | +|---|---| +| Local file path doesn't exist | Report the bad path. Ask if they meant a different file or want to re-upload. | +| URL not recognized by yt-dlp | Report the platform name. Ask Graeham to confirm an alternate path (manual download, Apify actor). | +| Video is private or restricted | Report the access error verbatim. Suggest verifying the URL is publicly viewable. | +| `pip install faster-whisper` fails on user's machine | Most common cause: very new Python version (3.14+) without wheels yet. Tell user to try `pip install faster-whisper --pre` or fall back to Python 3.12 in a venv. | +| Path contains `\U` or `\N` in a non-raw Python string | This actually happened. Always wrap Windows paths in raw strings (`r"..."`) or use forward slashes. Never put Windows paths in a docstring without escaping. | +| Video is very long (>60 min) | Tell the user the est. transcription time before kicking off, so they don't think it's stuck. | +| User has Python but no `faster-whisper` | Walk them through `pip install faster-whisper` first, then `python scripts/transcribe_windows.py ...` | + +## Setup requirements + +**On the user's Windows machine (one-time):** + +```powershell +pip install faster-whisper +``` + +ffmpeg must be on PATH. Graeham's lives at `C:\Users\Graeham Watts\Documents\Claude\ffmpegvideoprocessingengine\bin\` — the script adds this to PATH automatically. + +**No API keys required.** Everything runs locally and free. + +**Optional**: `OPENAI_API_KEY` if you want to use the OpenAI Whisper API (~10x faster, ~$0.006/min). Not currently wired into the Windows script — would need to be added if Graeham wants that path for super long videos. + +## Future agentic enhancement: watch-folder workflow + +The Windows script can be run from a "drop folder" pattern for fully hands-off transcription: + +1. Create `C:\Users\Graeham Watts\Documents\Transcribe-Inbox\` +2. Create `C:\Users\Graeham Watts\Documents\Transcribe-Done\` +3. PowerShell script (saved separately, not in this skill) polls inbox every 60 sec, transcribes anything new, moves source to Done folder and transcript next to it. +4. Wire to Windows Task Scheduler to start on login. + +This is NOT part of this skill yet — it's a separate setup. If Graeham asks for "drop folder transcription" or "agentic transcription," build that as a separate task. + +## Example: end-to-end run (PATH B, local file) + +**User uploads:** `webinar-recording.mp4` (68 min, 2.6 GB) + +**Skill flow:** +1. Detects local file path, no URL +2. Skips PATH A entirely (no captions on a local file) +3. Claude writes the transcribe command for the user to paste: + ```powershell + python "C:\Users\Graeham Watts\Documents\Claude\Skills\skills\video-transcriber\scripts\transcribe_windows.py" "C:\Users\...\webinar-recording.mp4" + ``` +4. User pastes it, faster-whisper runs (~5–15 min) +5. Two text files appear next to the video: plain + timestamped (if requested) +6. Claude reads them, summarizes findings, suggests follow-ons + +Total: ~10 minutes of compute, ~30 seconds of user time, $0. + +## Companion skill: video-watcher + +This skill captures **what was SAID** in a video. Its companion `video-watcher` captures **what was SHOWN** (frame-by-frame AI vision analysis — shot list, on-screen text catalog, production style fingerprint, Replicate-This Brief). + +They're standalone but compose naturally: + +- **"transcribe this video: [URL or file]"** → only video-transcriber fires (cheap, fast, words only) +- **"watch this video: [URL or file]"** → only video-watcher fires (vision analysis, costs API tokens) +- **"watch and transcribe"** / **"full breakdown of"** / **"make ours like this"** → BOTH fire in parallel and outputs are interleaved (audio transcript lines + visual shot-list notes, both timestamped) + +When in doubt about which the user wants: default to this skill (cheaper, more common need). If they want visual analysis specifically, they'll say "watch" or "shot list" or "make ours like this." + +## Why this exists + +Before this skill, transcription required: knowing which Python script lived where, knowing which platform was supported by which backend, knowing whether the sandbox had Whisper (it doesn't — disk too small), and stitching the output together manually. That's friction nobody on the team should have to navigate. + +This skill makes transcription a single move: drop a URL or a file, get a transcript. Done. + +## Maintenance + +- **yt-dlp updates**: When a platform's extractor breaks, `pip install -U yt-dlp` usually fixes it. The script can auto-run this when extractor failures are detected. +- **faster-whisper updates**: `pip install -U faster-whisper`. New model versions sometimes ship — re-download is automatic on first use of a new model name. +- **Python version drift**: Python 3.14 is fine but very new — some wheels lag. If `pip install faster-whisper` fails on a newer Python, fall back to a 3.12 venv. +- **Model storage**: Whisper models cache to `~/.cache/huggingface/hub/`. Each model is ~140–500 MB. Safe to delete if disk gets tight, will re-download on next use. diff --git a/skills/video-transcriber/scripts/transcribe.py b/skills/video-transcriber/scripts/transcribe.py new file mode 100755 index 0000000..ecce471 --- /dev/null +++ b/skills/video-transcriber/scripts/transcribe.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +transcribe.py — Universal video transcriber for Graeham Watts's team. + +Accepts a URL from any video platform (YouTube, Facebook, Instagram, TikTok, +Vimeo, Twitter/X, LinkedIn, Reddit, direct file, ~1,800+ supported by yt-dlp) +and returns a clean transcript. + +Tier 1: Caption pull (free, instant, ~1-3 sec) — only YouTube + some others +Tier 2: yt-dlp audio download + Whisper transcription (free, local, ~30 sec – 3 min) + +Usage: + python3 transcribe.py "https://www.youtube.com/watch?v=VIDEO_ID" + python3 transcribe.py "https://www.instagram.com/reel/REEL_ID/" --json + python3 transcribe.py "URL" --timestamps + python3 transcribe.py "URL" --save # write to outputs/transcripts/ + +Optional env vars: + OPENAI_API_KEY — use OpenAI Whisper API instead of local (faster, costs $0.006/min) + APIFY_API_TOKEN — fallback for platforms yt-dlp doesn't support + +No API keys required for the default free path. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + + +# --------------------------------------------------------------- +# Platform detection +# --------------------------------------------------------------- + +PLATFORM_PATTERNS = [ + ("youtube", r"(?:youtube\.com|youtu\.be)"), + ("facebook", r"(?:facebook\.com|fb\.watch)"), + ("instagram", r"instagram\.com"), + ("tiktok", r"tiktok\.com"), + ("vimeo", r"vimeo\.com"), + ("twitter", r"(?:twitter\.com|x\.com)"), + ("linkedin", r"linkedin\.com"), + ("reddit", r"reddit\.com"), + ("direct", r"\.(?:mp4|mov|m4a|mp3|wav|webm|mkv|avi)(?:\?|$)"), +] + + +def detect_platform(url: str) -> str: + """Return platform name based on URL pattern.""" + for name, pattern in PLATFORM_PATTERNS: + if re.search(pattern, url, re.IGNORECASE): + return name + return "unknown" + + +def youtube_video_id(url: str) -> str | None: + """Extract YouTube video ID from various URL formats.""" + for pattern in [ + r"(?:v=|/v/|youtu\.be/)([a-zA-Z0-9_-]{11})", + r"(?:embed/|shorts/)([a-zA-Z0-9_-]{11})", + ]: + m = re.search(pattern, url) + if m: + return m.group(1) + return None + + +# --------------------------------------------------------------- +# Dependency management +# --------------------------------------------------------------- + +def ensure_pip_package(pkg_name: str, import_name: str | None = None) -> bool: + """Install a pip package if it's not importable. Returns True on success.""" + mod = import_name or pkg_name.replace("-", "_") + try: + __import__(mod) + return True + except ImportError: + pass + + print(f"[setup] Installing {pkg_name}...", file=sys.stderr) + result = subprocess.run( + [sys.executable, "-m", "pip", "install", pkg_name, "--break-system-packages", "--quiet"], + capture_output=True, text=True + ) + if result.returncode != 0: + print(f"[setup] FAILED to install {pkg_name}: {result.stderr}", file=sys.stderr) + return False + return True + + +def ensure_ffmpeg() -> bool: + """Verify ffmpeg is available.""" + result = subprocess.run(["which", "ffmpeg"], capture_output=True, text=True) + if result.returncode == 0: + return True + print("[setup] ffmpeg not found — attempting apt install...", file=sys.stderr) + subprocess.run(["apt-get", "install", "-y", "ffmpeg"], capture_output=True) + return subprocess.run(["which", "ffmpeg"], capture_output=True).returncode == 0 + + +# --------------------------------------------------------------- +# Tier 1: Caption pull +# --------------------------------------------------------------- + +def caption_pull_youtube(video_id: str) -> dict | None: + """Try to fetch existing YouTube captions via youtube-transcript-api. Returns transcript dict or None.""" + if not ensure_pip_package("youtube-transcript-api"): + return None + try: + from youtube_transcript_api import YouTubeTranscriptApi + from youtube_transcript_api._errors import TranscriptsDisabled, NoTranscriptFound + except ImportError: + return None + + try: + segments = YouTubeTranscriptApi.get_transcript(video_id) + except (TranscriptsDisabled, NoTranscriptFound): + return None + except Exception as e: + print(f"[tier1] caption pull failed: {e}", file=sys.stderr) + return None + + text = " ".join(s["text"] for s in segments) + return { + "method": "caption_pull", + "language": "en", # api default; could be inspected for other langs + "segments": [ + {"start": s["start"], "end": s["start"] + s["duration"], "text": s["text"]} + for s in segments + ], + "transcript_plain": text, + } + + +# --------------------------------------------------------------- +# Tier 2: yt-dlp + Whisper +# --------------------------------------------------------------- + +def download_audio(url: str, tmpdir: str) -> str | None: + """Use yt-dlp to download the audio track. Returns path to mp3 file or None on failure.""" + if not ensure_pip_package("yt-dlp"): + return None + if not ensure_ffmpeg(): + print("[tier2] ffmpeg unavailable, cannot extract audio", file=sys.stderr) + return None + + output_template = os.path.join(tmpdir, "audio.%(ext)s") + cmd = [ + sys.executable, "-m", "yt_dlp", + "-x", "--audio-format", "mp3", + "--audio-quality", "0", + "-o", output_template, + "--quiet", "--no-warnings", + url, + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + # Try updating yt-dlp once and retry (handles broken extractors) + print("[tier2] yt-dlp failed, updating and retrying...", file=sys.stderr) + subprocess.run([sys.executable, "-m", "pip", "install", "-U", "yt-dlp", "--break-system-packages", "--quiet"], capture_output=True) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"[tier2] yt-dlp failed after retry: {result.stderr}", file=sys.stderr) + return None + + audio_path = os.path.join(tmpdir, "audio.mp3") + if not os.path.exists(audio_path): + return None + return audio_path + + +def whisper_transcribe(audio_path: str, model_size: str = "base") -> dict | None: + """ + Transcribe audio with Whisper. Prefers OpenAI API if OPENAI_API_KEY is set + (faster, ~$0.006/min); falls back to local Whisper otherwise (free, slower). + """ + api_key = os.environ.get("OPENAI_API_KEY") + + if api_key: + # OpenAI Whisper API path + if not ensure_pip_package("openai"): + api_key = None # fall back to local + else: + try: + from openai import OpenAI + client = OpenAI(api_key=api_key) + with open(audio_path, "rb") as f: + resp = client.audio.transcriptions.create( + model="whisper-1", + file=f, + response_format="verbose_json", + timestamp_granularities=["segment"], + ) + segments = [ + {"start": s.start, "end": s.end, "text": s.text} + for s in (resp.segments or []) + ] + return { + "method": "openai_whisper_api", + "language": resp.language, + "segments": segments, + "transcript_plain": resp.text, + } + except Exception as e: + print(f"[tier2] OpenAI API failed: {e}, falling back to local Whisper", file=sys.stderr) + + # Local Whisper path + if not ensure_pip_package("openai-whisper", import_name="whisper"): + return None + import whisper + print(f"[tier2] Loading Whisper '{model_size}' model (first run downloads ~140 MB)...", file=sys.stderr) + model = whisper.load_model(model_size) + print(f"[tier2] Transcribing... this can take 30 sec – 3 min depending on length", file=sys.stderr) + result = model.transcribe(audio_path, verbose=False) + segments = [ + {"start": s["start"], "end": s["end"], "text": s["text"].strip()} + for s in result.get("segments", []) + ] + return { + "method": "local_whisper", + "language": result.get("language", "unknown"), + "segments": segments, + "transcript_plain": result.get("text", "").strip(), + } + + +# --------------------------------------------------------------- +# Metadata extraction +# --------------------------------------------------------------- + +def fetch_metadata(url: str) -> dict: + """Use yt-dlp to grab title + duration without downloading the video.""" + if not ensure_pip_package("yt-dlp"): + return {} + cmd = [ + sys.executable, "-m", "yt_dlp", + "--dump-single-json", "--no-warnings", "--skip-download", + url, + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + return {} + try: + info = json.loads(result.stdout) + return { + "title": info.get("title"), + "duration_sec": info.get("duration"), + "uploader": info.get("uploader") or info.get("channel"), + "upload_date": info.get("upload_date"), + } + except json.JSONDecodeError: + return {} + + +# --------------------------------------------------------------- +# Main orchestration +# --------------------------------------------------------------- + +def transcribe_url(url: str) -> dict: + """Top-level entry: detect platform, run the right tier, return a normalized dict.""" + platform = detect_platform(url) + + # Tier 1 for YouTube (fast, free, often works) + if platform == "youtube": + vid = youtube_video_id(url) + if vid: + t1 = caption_pull_youtube(vid) + if t1: + meta = fetch_metadata(url) + return { + "url": url, + "platform": platform, + "tier": 1, + **meta, + **t1, + } + + # Tier 2: yt-dlp + Whisper (works for all platforms yt-dlp supports) + meta = fetch_metadata(url) + with tempfile.TemporaryDirectory() as tmpdir: + audio = download_audio(url, tmpdir) + if not audio: + return { + "url": url, + "platform": platform, + "error": "audio_download_failed", + "message": "yt-dlp could not download audio for this URL. The platform may be unsupported, the video may be private/restricted, or the extractor may need updating.", + } + t2 = whisper_transcribe(audio) + if not t2: + return { + "url": url, + "platform": platform, + "error": "whisper_failed", + "message": "Whisper transcription failed. Check OPENAI_API_KEY or local Whisper install.", + } + return { + "url": url, + "platform": platform, + "tier": 2, + **meta, + **t2, + } + + +# --------------------------------------------------------------- +# Output formatting +# --------------------------------------------------------------- + +def format_timestamp(seconds: float) -> str: + """Format seconds as MM:SS or HH:MM:SS.""" + s = int(seconds) + h, rem = divmod(s, 3600) + m, s = divmod(rem, 60) + return f"{h:02d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}" + + +def render_plain(result: dict, with_timestamps: bool = False) -> str: + """Render the result as readable plain text.""" + if "error" in result: + return f"ERROR: {result['error']} — {result.get('message', '')}\n" + + lines = [] + if result.get("title"): + lines.append(f"Title: {result['title']}") + lines.append(f"Platform: {result.get('platform', 'unknown')}") + if result.get("duration_sec"): + lines.append(f"Duration: {format_timestamp(result['duration_sec'])}") + if result.get("uploader"): + lines.append(f"Uploader: {result['uploader']}") + lines.append(f"Language: {result.get('language', 'unknown')}") + lines.append(f"Method: {result.get('method', 'unknown')}") + lines.append("") + + if with_timestamps and result.get("segments"): + for seg in result["segments"]: + ts = format_timestamp(seg["start"]) + lines.append(f"[{ts}] {seg['text']}") + else: + lines.append(result.get("transcript_plain", "")) + + return "\n".join(lines) + "\n" + + +def save_to_file(result: dict, output_dir: Path, fmt: str = "txt", with_timestamps: bool = False) -> Path: + """Persist the transcript to outputs/transcripts/ for later reference.""" + output_dir.mkdir(parents=True, exist_ok=True) + platform = result.get("platform", "video") + title_slug = re.sub(r"[^a-z0-9]+", "-", (result.get("title") or "untitled").lower()).strip("-")[:50] + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + filename = f"transcript-{platform}-{title_slug}-{ts}.{fmt}" + path = output_dir / filename + + if fmt == "json": + path.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") + else: + path.write_text(render_plain(result, with_timestamps=with_timestamps), encoding="utf-8") + return path + + +# --------------------------------------------------------------- +# CLI +# --------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Universal video transcriber") + parser.add_argument("url", help="Video URL (YouTube, Facebook, Instagram, TikTok, etc.)") + parser.add_argument("--json", action="store_true", help="Output JSON instead of plain text") + parser.add_argument("--timestamps", action="store_true", help="Include [MM:SS] timestamps per segment") + parser.add_argument("--save", action="store_true", help="Also save to outputs/transcripts/") + parser.add_argument("--output-dir", default="outputs/transcripts", help="Output directory when --save is set") + args = parser.parse_args() + + print(f"[transcribe] URL: {args.url}", file=sys.stderr) + print(f"[transcribe] Platform detected: {detect_platform(args.url)}", file=sys.stderr) + + result = transcribe_url(args.url) + + if args.save: + out_path = save_to_file( + result, + Path(args.output_dir), + fmt="json" if args.json else "txt", + with_timestamps=args.timestamps, + ) + print(f"[transcribe] Saved to: {out_path}", file=sys.stderr) + + if args.json: + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(render_plain(result, with_timestamps=args.timestamps)) + + +if __name__ == "__main__": + main() diff --git a/skills/video-transcriber/scripts/transcribe_windows.py b/skills/video-transcriber/scripts/transcribe_windows.py new file mode 100644 index 0000000..0b066cf --- /dev/null +++ b/skills/video-transcriber/scripts/transcribe_windows.py @@ -0,0 +1,186 @@ +# transcribe_windows.py +# Local Windows transcription using faster-whisper (CPU, int8). +# Handles both local file paths and URLs (yt-dlp downloads audio first for URLs). +# +# Usage from PowerShell: +# python transcribe_windows.py "C:\path\to\video.mp4" +# python transcribe_windows.py "https://www.youtube.com/watch?v=..." +# python transcribe_windows.py "C:\path\to\video.mp4" --model small.en --timestamps +# +# Why faster-whisper instead of openai-whisper: +# - 4-10x faster on CPU +# - Smaller install (no PyTorch dependency) +# - Same accuracy +# Why int8 quantization: +# - Roughly 2x faster on CPU vs fp32 with negligible quality loss for English speech + +import argparse +import os +import re +import subprocess +import sys +import tempfile +import time +from datetime import timedelta + +# Graeham's ffmpeg location. Adjust if it moves. +FFMPEG_DIR = r"C:\Users\Graeham Watts\Documents\Claude\ffmpegvideoprocessingengine\bin" + + +def ensure_ffmpeg_on_path(): + """faster-whisper shells out to ffmpeg to decode audio. Add it to PATH if not visible.""" + if FFMPEG_DIR and os.path.isdir(FFMPEG_DIR): + if FFMPEG_DIR not in os.environ.get("PATH", ""): + os.environ["PATH"] = FFMPEG_DIR + os.pathsep + os.environ.get("PATH", "") + + +def is_url(s: str) -> bool: + return s.lower().startswith(("http://", "https://")) + + +def download_audio_with_ytdlp(url: str, tmpdir: str) -> str: + """For URL inputs: pull just the audio track to a temp mp3 with yt-dlp.""" + try: + import yt_dlp # noqa: F401 + except ImportError: + print("[*] Installing yt-dlp (one-time)...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "yt-dlp", "--quiet"]) + + out_template = os.path.join(tmpdir, "audio.%(ext)s") + cmd = [ + sys.executable, "-m", "yt_dlp", + "-x", "--audio-format", "mp3", + "--audio-quality", "0", + "-o", out_template, + "--quiet", "--no-warnings", + url, + ] + print(f"[*] Downloading audio from URL...") + subprocess.check_call(cmd) + audio_path = os.path.join(tmpdir, "audio.mp3") + if not os.path.exists(audio_path): + raise RuntimeError("yt-dlp completed but no audio.mp3 was produced") + return audio_path + + +def slugify(name: str) -> str: + base = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-") + return base[:80] if base else "transcript" + + +def transcribe(source: str, model_size: str, output_dir: str, with_timestamps: bool): + ensure_ffmpeg_on_path() + + # Resolve source to a local audio/video path + tmpdir_obj = None + if is_url(source): + tmpdir_obj = tempfile.TemporaryDirectory() + audio_path = download_audio_with_ytdlp(source, tmpdir_obj.name) + source_label = source + slug = slugify(source.split("/")[-1] or "url") + else: + if not os.path.exists(source): + print(f"[!] ERROR: file not found: {source}") + sys.exit(1) + audio_path = source + source_label = os.path.basename(source) + slug = slugify(os.path.splitext(os.path.basename(source))[0]) + + size_mb = os.path.getsize(audio_path) / (1024 * 1024) + print(f"[*] Source: {source_label}") + print(f"[*] Size: {size_mb:.1f} MB") + print(f"[*] Model: {model_size} (int8 quantization, CPU)") + print(f"[*] Loading faster-whisper... (first run downloads the model)") + + try: + from faster_whisper import WhisperModel + except ImportError: + print("[!] faster-whisper not installed. Run: pip install faster-whisper") + sys.exit(1) + + model = WhisperModel(model_size, device="cpu", compute_type="int8") + print(f"[*] Model loaded. Starting transcription...") + + start = time.time() + segments, info = model.transcribe( + audio_path, + beam_size=5, + vad_filter=True, + vad_parameters=dict(min_silence_duration_ms=500), + ) + + print(f"[*] Detected language: {info.language} (confidence: {info.language_probability:.2f})") + print(f"[*] Audio duration: {timedelta(seconds=int(info.duration))} ({info.duration/60:.1f} min)") + + all_segments = [] + for i, seg in enumerate(segments): + all_segments.append(seg) + if i % 20 == 0: + elapsed = time.time() - start + progress = seg.end / info.duration * 100 + eta = (elapsed / max(seg.end, 1)) * (info.duration - seg.end) + print(f" [{progress:5.1f}%] t={timedelta(seconds=int(seg.end))} elapsed={int(elapsed)}s eta~{int(eta)}s") + + elapsed = time.time() - start + print(f"[*] Transcription complete in {elapsed:.0f}s ({elapsed/60:.1f} min)") + print(f"[*] Got {len(all_segments)} segments") + + # Write outputs + os.makedirs(output_dir, exist_ok=True) + plain_path = os.path.join(output_dir, f"{slug}_transcript.txt") + ts_path = os.path.join(output_dir, f"{slug}_transcript_timestamped.txt") + + with open(plain_path, "w", encoding="utf-8") as f: + f.write(f"Transcript: {source_label}\n") + f.write(f"Duration: {timedelta(seconds=int(info.duration))}\n") + f.write(f"Language: {info.language}\n") + f.write(f"Model: faster-whisper {model_size} (int8)\n") + f.write("=" * 70 + "\n\n") + para = [] + for i, seg in enumerate(all_segments): + para.append(seg.text.strip()) + if (i + 1) % 5 == 0: + f.write(" ".join(para) + "\n\n") + para = [] + if para: + f.write(" ".join(para) + "\n") + + if with_timestamps: + with open(ts_path, "w", encoding="utf-8") as f: + f.write(f"Transcript (timestamped): {source_label}\n") + f.write("=" * 70 + "\n\n") + for seg in all_segments: + start_str = str(timedelta(seconds=int(seg.start))) + f.write(f"[{start_str}] {seg.text.strip()}\n") + + print(f"\n[OK] Plain transcript: {plain_path}") + if with_timestamps: + print(f"[OK] Timestamped transcript: {ts_path}") + print(f"\nDone.") + + if tmpdir_obj: + tmpdir_obj.cleanup() + + +def main(): + p = argparse.ArgumentParser(description="Local Windows transcription using faster-whisper") + p.add_argument("source", help="Local file path OR URL") + p.add_argument("--model", default="base.en", + help="Model size. base.en (default, fast), small.en (slower, more accurate), medium.en (slowest, best)") + p.add_argument("--output-dir", default=None, + help="Where to write transcript .txt files. Default: same folder as input.") + p.add_argument("--timestamps", action="store_true", + help="Also write a timestamped version") + args = p.parse_args() + + if args.output_dir is None: + if is_url(args.source): + args.output_dir = os.getcwd() + else: + args.output_dir = os.path.dirname(os.path.abspath(args.source)) + + transcribe(args.source, args.model, args.output_dir, args.timestamps) + + +if __name__ == "__main__": + main() diff --git a/skills/video-watcher/SKILL.md b/skills/video-watcher/SKILL.md new file mode 100755 index 0000000..ca4fa18 --- /dev/null +++ b/skills/video-watcher/SKILL.md @@ -0,0 +1,255 @@ +--- +name: video-watcher +description: "AI video analysis skill for Graeham Watts's team. Paste any video URL and the system literally WATCHES the video frame-by-frame using vision AI, then returns a structured blueprint of how it was made — shot list, on-screen text catalog, production style fingerprint, and a Replicate-This Brief telling you exactly how to recreate it with HeyGen + Higgsfield + Remotion. Pairs with video-transcriber (what was said) to give complete A+V understanding of any video. Use this skill ANY time the user mentions: watch this video, analyze this video, video analysis, what's in this video, visual analysis, shot list, shot breakdown, scene breakdown, scenes, video blueprint, recreate this video, make ours like this, how is this video made, what shots are in, what visuals are in, B-roll catalog, B-roll breakdown, on-screen text, text overlays in video, video production style, video editing style, cut analysis, pacing analysis, frame-by-frame, frame analysis, video reference, reference video, competitor video. Also trigger when the user pastes a video URL and asks for a description or breakdown (vs just a transcript), or says 'make ours like this' followed by a URL. Distinct from video-transcriber: transcriber extracts WORDS only; video-watcher extracts VISUAL structure. Both can run together for complete coverage." +--- + +# Video Watcher + +> **One job:** Watch any video with AI vision. Output a literal blueprint of how to recreate it. + +This skill exists because the transcript of a video only tells you what was *said* — it tells you nothing about what was *shown*. For Peter and Ellie editing video content, the visual structure matters as much as the script. video-watcher closes that gap. + +## The two-skill split (read this first) + +This skill pairs with `video-transcriber`. They're standalone but compose naturally: + +| Skill | What it captures | +|---|---| +| **video-transcriber** | What was SAID — every word, timestamped | +| **video-watcher** | What was SHOWN — every visual beat, timestamped | + +Use video-transcriber alone when you only need the words (pulling quotes for cuts, blog source material). Use video-watcher alone when you only need the visual blueprint (replicating a reference video's style). Use BOTH together when you need complete understanding of a video to recreate it end-to-end. + +The user can invoke both at once by saying something like: +> "Watch and transcribe this video: [URL]" +or +> "Full breakdown of this video: [URL]" + +In which case both skills fire in parallel and the output is interleaved. + +## Who uses this + +**Peter and Ellie** (video editors): use this when Graeham sends them a reference video saying "make ours like this." The skill returns a shot list with exact timestamps telling them what shot type to use when, where text overlays go, what B-roll to pull, what color grade to match, and what pacing to hit. + +**Graeham**: use this to study any reference video personally — competitor analysis, "this viral Reel got 2M views, why" investigations, evaluating whether to commission a specific style for a new campaign. + +**John** (Blog Track): use this to extract the visual structure of a video for blog posts that include "here's how this video is made" content. + +**content-creation-engine**: uses this internally during Phase 0 Mode B (visual analysis pass) when generating new content from a reference video source. The engine no longer owns this code — it calls this skill as an external dependency. + +## How to invoke + +The simplest possible UX. Any of these work: + +1. **Paste URL + watch verb**: + ``` + watch this: https://www.youtube.com/watch?v=PYMsmSx8Tyw + ``` +2. **Reference replication phrase**: + ``` + make ours like this: https://www.instagram.com/reel/DXPuASugkgy/ + ``` +3. **Full breakdown request**: + ``` + full breakdown of https://www.tiktok.com/@user/video/ABC + ``` +4. **Combined with transcriber**: + ``` + watch AND transcribe: https://www.youtube.com/watch?v=... + ``` + +The skill auto-detects the platform from the URL (yt-dlp supports 1,800+ sites) and routes through the right backend. + +## What you get back + +A structured markdown document with these sections: + +``` +TLDR + 3-4 sentences synthesizing the whole video. What it's + trying to accomplish, who it's for, what it does well. + +Hooks (0:00 - 0:10) + The opening 10 seconds analyzed in detail because that's + where scroll-stoppers live. Visual + audio + analysis. + +Per-Scene Notes + For each scene change (typically 8-80 frames per video): + [Timestamp] + On-screen text: exact transcription of any text overlay + Visual: 1-2 sentences describing what's on screen + Said: 1-line quote/paraphrase of the spoken line + Synthesis: 1-2 sentences on why this beat matters + +Key Concepts + Bulleted list with timestamps. The 3-5 ideas the video + communicates most clearly. + +B-Roll Catalog + Table of shot types with timestamps and rough %. + Example: drone aerials 12%, talking head 60%, screen + recording 8%, text-overlay-on-photo 20%. + +On-Screen Text Catalog + Table of every visible text overlay with timestamp, + exact text, and styling notes (color, font feel, size). + +Production Style Fingerprint + Color grade, typography, motion graphics style, framing, + any visible brand signals. What makes this video LOOK + the way it does. + +Code & Commands + If any code, terminal output, or technical commands + appear on screen, transcribed as fenced code blocks. + +Replicate-This Brief ← the killer output + What you'd tell HeyGen + Higgsfield + Remotion + CapCut + to recreate this video's structure. Concrete instructions. + Example: + - HeyGen avatar with warm desk look (Vaibhav template #3) + - Higgsfield drone aerial of Palo Alto / Menlo Park + - 3 Remotion stat-callout overlays at 0:08, 0:25, 0:42 + - Color grade: warm/teal split + - Cut pacing: 8 cuts in 90 seconds + +Open Questions + Anything visible in the video that the analyzer couldn't + fully interpret. (e.g., "What is that gold UI element at + 0:34? Looks like a custom branded watermark.") +``` + +This document IS the blueprint. Peter and Ellie can work directly from it. + +## How the pipeline works (under the hood) + +The skill chains four steps. The user doesn't see this — they just paste a URL and wait 30 sec to 5 min depending on video length. + +``` +1. download.py yt-dlp pulls the video file + ↓ +2. transcribe.py Get the transcript (caption API or Whisper) + ↓ +3. frames.py Smart frame extraction — scene-change detection + + coverage floor (1 frame per N seconds). + Caps at 80 frames per video. + ↓ +4. analyze.py Builds a bundle pairing each frame with its + ±15-second transcript window. Claude (the + invoking skill) reads each frame as multimodal + vision input and writes the structured notes. +``` + +The vision pass uses Claude's built-in multimodal capability — same model that handles image uploads in chat. No separate API key required (the Cowork environment is already Claude-powered). + +For videos longer than 10 minutes, the skill confirms with the user before starting the vision pass (cost ramps up with frame count). + +## Trigger boundaries — when this skill fires vs siblings + +| User says | Skill that fires | +|---|---| +| "transcribe this video" | video-transcriber only | +| "watch this video" | video-watcher only | +| "full breakdown" / "make ours like this" | video-watcher (often also pulls in transcriber for the audio side) | +| "what's in this video" | video-watcher (visual) — though if context suggests "what was said" then transcriber | +| URL alone with no verb | Default: video-transcriber (faster, cheaper, more common need). User can clarify "watch instead" to flip. | +| "generate a blog post from this video" | content-creation-engine (which internally may call video-watcher + video-transcriber) | + +When in doubt: ask. Don't burn $0.80 of vision API on the wrong tool. + +## Optional flags the user can request + +- **"just the shot list"** — skip TLDR, hooks, etc. Return only the B-Roll Catalog + Per-Scene Notes +- **"just the Replicate-This brief"** — skip everything except the recreation instructions +- **"first 30 seconds only"** — analyze only the opening (cheap fast scan for hook study) +- **"save to file"** — write the analysis to `outputs/video-analysis/analysis-{slug}-{timestamp}.md` +- **"and find the cuts"** — produce a cut-list optimized for Premiere editing reference +- **"with the transcript inline"** — invoke video-transcriber too and interleave the spoken lines into the shot list + +## Cost honesty + +Vision API isn't free. Rough estimates per video: + +| Video length | Frames extracted | Approx cost | +|---|---|---| +| 30-second Reel | 5-10 frames | $0.05-$0.15 | +| 90-second Short | 10-20 frames | $0.10-$0.30 | +| 5-minute video | 30-50 frames | $0.30-$0.80 | +| 30-minute interview | 60-80 frames (capped) | $0.50-$1.50 | + +For Peter and Ellie running 2-3 reference videos per week: roughly $5-15/month total. For Graeham doing competitor sweeps: depends on volume. + +The skill ALWAYS reports estimated frame count before kicking off the vision pass on long videos. The user can abort if the cost feels high. + +## Setup requirements + +The Cowork sandbox auto-installs these on first run: + +```bash +pip install yt-dlp youtube-transcript-api openai-whisper --break-system-packages +apt install -y ffmpeg # usually already installed +``` + +For Peter and Ellie's local installs, the skill auto-installs dependencies on first run too. They might see a one-time "installing yt-dlp..." message on the first invocation; subsequent runs are immediate. + +**Optional env vars** (none required for the default flow): +- `OPENAI_API_KEY` — uses OpenAI Whisper API for faster transcription on long videos (the audio path costs $0.006/min) +- `APIFY_API_TOKEN` — fallback for niche platforms yt-dlp doesn't support +- `DATAFORSEO_LOGIN` + `DATAFORSEO_PASSWORD` — Tier 0 caption pull for YouTube videos (free per pull, $0.004 if used) + +## Example: end-to-end run + +**Peter pastes:** +``` +make ours like this: https://www.instagram.com/reel/DXNXXXX/ +``` + +**Skill flow:** +1. Detects: Instagram Reel +2. Estimates cost: ~15 frames × vision API ≈ $0.20 +3. Asks Peter to confirm (auto-skips this prompt for short videos) +4. Runs yt-dlp to download the Reel (~20 MB, 5 sec) +5. Runs frames.py — extracts 12 frames at scene changes +6. Runs transcribe.py — pulls 90-second transcript +7. Runs analyze.py — builds the multimodal bundle +8. Claude reads each frame + surrounding transcript context +9. Writes the full structured markdown analysis +10. Returns to Peter as a clean document he can paste into Premiere notes + +Total: ~90 seconds. Output: a blueprint he can shoot from tomorrow. + +## How content-creation-engine uses this + +The engine's Phase 0 (Source Ingestion) has two modes: +- **Mode A (transcript only)** — default for most content generation tasks +- **Mode B (transcript + visual analysis)** — invoked when the user wants to replicate a reference video's style, not just its message + +In Mode B, content-creation-engine now calls `video-watcher` as an external skill instead of running the embedded analysis code. The output flows into the script-writer Phase to inform shot direction in the generated content package (which inline shot tags to use, what B-roll types to source, what production style fingerprint to match). + +This means content-creation-engine becomes a *composer* of skills rather than an *owner* of code. It's cleaner architecture and makes each capability discoverable as its own skill. + +## Failure handling + +| Failure | What the skill does | +|---|---| +| URL not recognized by yt-dlp | Reports the platform, asks user to confirm an alternate path | +| Video is private or geo-blocked | Reports the access error verbatim | +| Frame extraction fails (corrupt video) | Falls back to coverage-floor sampling only (drop scene detection) | +| Vision API rate-limited | Backs off, retries; if persistent, falls back to text-only analysis using existing transcript + filename + duration | +| Video is very long (>30 min) | Confirms with user before kicking off (cost concern) | +| Network access blocked (Cowork sandbox firewall) | Reports the block honestly, suggests running locally on user's machine instead | + +## Maintenance + +The 6 Python scripts in `scripts/` were lifted from `content-creation-engine/scripts/video-research/` on 2026-05-15. They are now the canonical owners of this logic. If content-creation-engine still has copies, those are deprecated — refer here. + +When yt-dlp's platform list expands, frame extraction expands automatically. When Claude's vision model improves, the analysis output improves automatically. No code changes needed in this skill. + +## Why this exists (history) + +Before this skill, visual analysis of a video required: +1. Knowing about content-creation-engine's Phase 0 Mode B (most users didn't) +2. Explicitly opting into Mode B in a content-engine invocation (most invocations didn't) +3. Going through all the content-engine ceremony just to get a shot list + +That meant the capability existed in the codebase but was effectively dormant. Extracting it into a standalone skill with the right trigger keywords makes it discoverable and usable on its own. The 6 worker scripts (download/frames/transcribe/analyze/library/dataforseo) didn't change — only the wrapper changed. diff --git a/skills/video-watcher/references/ffmpeg-trimming.md b/skills/video-watcher/references/ffmpeg-trimming.md new file mode 100755 index 0000000..5793690 --- /dev/null +++ b/skills/video-watcher/references/ffmpeg-trimming.md @@ -0,0 +1,38 @@ +--- +name: ffmpeg +description: Using FFmpeg and FFprobe in Remotion +metadata: + tags: ffmpeg, ffprobe, video, trimming +--- + +## FFmpeg in Remotion + +`ffmpeg` and `ffprobe` do not need to be installed. They are available via the `bunx remotion ffmpeg` and `bunx remotion ffprobe`: + +```bash +bunx remotion ffmpeg -i input.mp4 output.mp3 +bunx remotion ffprobe input.mp4 +``` + +### Trimming videos + +You have 2 options for trimming videos: + +1. Use the FFmpeg command line. You MUST re-encode the video to avoid frozen frames at the start of the video. + +```bash +# Re-encodes from the exact frame +bunx remotion ffmpeg -ss 00:00:05 -i public/input.mp4 -to 00:00:10 -c:v libx264 -c:a aac public/output.mp4 +``` + +2. Use the `trimBefore` and `trimAfter` props of the `