[object Object]

← back to Contact Mailer Ideas

daily generator: claude CLI → 20 fresh ideas every 5am PT, batches/ dir, viewer batch-switcher dropdown, launchd com.steve.contact-mailer-daily

045f74bb87b17238c4a9889f042d1123d01ba12d · 2026-05-11 12:38:51 -0700 · Steve Abrams

Files touched

Diff

commit 045f74bb87b17238c4a9889f042d1123d01ba12d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 12:38:51 2026 -0700

    daily generator: claude CLI → 20 fresh ideas every 5am PT, batches/ dir, viewer batch-switcher dropdown, launchd com.steve.contact-mailer-daily
---
 batches/2026-05-11.json | 230 ++++++++++++++++++++++++++++++++++++++++++++++++
 generate-daily.sh       | 125 ++++++++++++++++++++++++++
 sync-to-prod.sh         |   3 +
 viewer/server.js        |  86 ++++++++++++++++--
 4 files changed, 435 insertions(+), 9 deletions(-)

diff --git a/batches/2026-05-11.json b/batches/2026-05-11.json
new file mode 100644
index 0000000..5e4f18a
--- /dev/null
+++ b/batches/2026-05-11.json
@@ -0,0 +1,230 @@
+{
+  "batch": "contact-mailer-cc-01",
+  "vertical": "contact-mailer / email-marketing (Constant Contact-adjacent)",
+  "authority": "ideate-only",
+  "generated_at": "2026-05-11T17:30:00-07:00",
+  "figma_file_key": "1hHZQ0xggOqzVuPIB0rD3r",
+  "figma_file_url": "https://www.figma.com/design/1hHZQ0xggOqzVuPIB0rD3r",
+  "ideas": [
+    {
+      "idx": 1, "name": "AddressAI", "node_id": "1:2",
+      "angle": "AI list hygiene at import time",
+      "target": "SMB email senders churning on bounces",
+      "diff": "Predicts deliverability per-contact BEFORE first send; bounces never hit your sender rep",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Pull 5 friend SMBs' CC/Mailchimp list exports, run them through NeverBounce + Hunter free tier, compare predicted-vs-actual bounce rates on next send" },
+        { "step": "MVP", "what": "CSV-in → scored-CSV-out web app. Integrate NeverBounce ($0.008/check) + add an 'AI confidence' score from contact metadata. No login, $1 per 100 contacts" },
+        { "step": "DISTRIBUTION", "what": "Cold-pitch ESP migration agencies + post case studies on r/EmailMarketing showing 'we saved $X by trimming dead contacts before billing tier rollover'" }
+      ]
+    },
+    {
+      "idx": 2, "name": "VoiceMailer", "node_id": "1:16",
+      "angle": "AI video greeting (selfie + voice sample)",
+      "target": "Real estate, course creators, financial advisors",
+      "diff": "AI video greeting (face+voice) generated from one selfie + one voice sample, personalized per recipient — clicks to inbox-friendly hosted page",
+      "action_items": [
+        { "step": "VALIDATE", "what": "A/B test on 200 recipients: template email vs same email with 'Watch a 15-sec note from me' personalized HeyGen-style video link. Measure click + reply rate" },
+        { "step": "MVP", "what": "Upload 1 selfie + 30s voice sample → generate per-recipient videos via HeyGen/Synthesia API + host on cloudfront, embed thumbnail in email. $49/mo, 500 videos" },
+        { "step": "DISTRIBUTION", "what": "Tinder-style cold outreach to top 1,000 real-estate IG accounts via Manychat partner; offer first 100 videos free in exchange for testimonial" }
+      ]
+    },
+    {
+      "idx": 3, "name": "PostNeighbor", "node_id": "1:30",
+      "angle": "NCOA new-mover detection → postcard → email opt-in funnel",
+      "target": "Brick-and-mortar SMBs (storefronts, services)",
+      "diff": "NCOA new-mover signal triggers Lob postcard w/ QR offer, scan = consented email opt-in to storefront list",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Buy a 1mo NCOA new-mover feed for one ZIP, send 200 hand-designed postcards w/ trackable QR for a local DW customer (cork wallpaper storefront), measure scan→opt-in rate" },
+        { "step": "MVP", "what": "Storefront enters address → we draw 1-mile radius → pull new movers monthly → auto-print/mail via Lob with their branded postcard → opt-ins flow to their existing ESP via Zapier" },
+        { "step": "DISTRIBUTION", "what": "Partner with 1 local-chamber-of-commerce in LA — pilot price $99/mo + $1/postcard mailed. Steve's existing dw_unified leverages the trade list" }
+      ]
+    },
+    {
+      "idx": 4, "name": "OneSendNoApp", "node_id": "1:44",
+      "angle": "No-UI, reply-to-send campaigns",
+      "target": "Hate-the-dashboard solo operators",
+      "diff": "Reply to send@<addr> = your campaign. Zero login, zero builder",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Manually run 5 solo-founder lists for 2 weeks via private SMTP relay where they email send@stevemail.co and we forward to their list. Measure delight + retention" },
+        { "step": "MVP", "what": "Each customer gets a unique send@<slug>.onesend.co inbox. Inbound parser strips signature, validates against safety list, blasts via Postmark, replies-to-sender go back to them" },
+        { "step": "DISTRIBUTION", "what": "ProductHunt launch with 'the anti-Mailchimp' positioning — show 5-sec demo of replying to send → analytics dashboard via email reply too" }
+      ]
+    },
+    {
+      "idx": 5, "name": "WallcoverDM", "node_id": "1:58",
+      "angle": "Vertical: B2B trade (design industry)",
+      "target": "Wallcovering / fabric / showroom vendors",
+      "diff": "Trade pricing in-email, sample-request one-click, showroom RSVP tracked — built for the design trade",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Run a 3-email sequence to dw_unified's existing trade list (designers w/ active accounts) with one-click sample request — measure click→sample-shipped→reorder funnel" },
+        { "step": "MVP", "what": "Vertical ESP w/ pre-built blocks: 'trade price card', 'sample request CTA', 'showroom RSVP'. Integrates direct to Shopify product variants + sample SKU tracking" },
+        { "step": "DISTRIBUTION", "what": "Sell into DW's own vendor partners first (Schumacher, Thibaut, Arte, etc.) at $299/mo as 'designer-fluent email infra'. DW gets affiliate cut" }
+      ]
+    },
+    {
+      "idx": 6, "name": "AftermarketCC", "node_id": "1:72",
+      "angle": "Reactivation of dead segments",
+      "target": "Mailchimp/CC users w/ 60%+ unopened-90d",
+      "diff": "Imports your CC/MC export, runs isolated-IP win-back sequences without contaminating main sender rep",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Run isolated-IP win-back for 3 friend SMBs' dead segments (5k+ contacts each, 90d+ no-open). Target metric: 8%+ reactivation w/ <0.5% spam complaints" },
+        { "step": "MVP", "what": "Upload CSV export → we filter to >90d no-open + run via dedicated /24 IP pool with 3-touch sequence over 14 days → re-engaged contacts handed back as CSV" },
+        { "step": "DISTRIBUTION", "what": "Pay-per-reactivation pricing: $0.50 per contact that opens/clicks. Pitch directly via cold email to ESPs' lapsed-engagement users (LinkedIn scrape)" }
+      ]
+    },
+    {
+      "idx": 7, "name": "LeaveBehind", "node_id": "1:86",
+      "angle": "Email→postcard hybrid via Lob",
+      "target": "Real-estate, local services, B2B SDRs",
+      "diff": "Top non-openers auto-get a printed postcard; one flow bridges email + USPS",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Hand-deploy postcard-after-email for 3 real-estate agents over 2 campaigns. Measure: postcard→reply rate vs control (email-only). Hypothesis: 3-5x lift on non-openers" },
+        { "step": "MVP", "what": "Plugin/SDK that hooks any ESP → after 7 days, exports non-openers w/ valid mailing address → sends through Lob with templated postcard. $99/mo + $1.50/postcard" },
+        { "step": "DISTRIBUTION", "what": "Mailchimp/Klaviyo app marketplace listing as 'postcard backup for non-openers' — single integration with the highest-volume ESPs" }
+      ]
+    },
+    {
+      "idx": 8, "name": "PaidPress", "node_id": "1:100",
+      "angle": "SMB newsletter monetization",
+      "target": "Newsletter operators sub-Beehiiv scale",
+      "diff": "Auto-matched $50–$200 sponsor slot per send from a vertical-specific marketplace",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Manually match 5 SMB newsletters (500-5k subs) with 1 sponsor each for one send. Measure: did the sponsor reorder? did the publisher accept future slots?" },
+        { "step": "MVP", "what": "Publisher embeds <iframe> in their template → on send, our server picks the highest-CPM matched sponsor + injects creative. 70/30 publisher/platform split" },
+        { "step": "DISTRIBUTION", "what": "Recruit sponsors via Beehiiv Ads + SponsorPlease alumni lists; recruit publishers from Substack's SMB tier (Substack doesn't monetize sub-1k well)" }
+      ]
+    },
+    {
+      "idx": 9, "name": "FormForward", "node_id": "1:114",
+      "angle": "Smart routing forms → flows",
+      "target": "Multi-audience SMBs (wholesale + consumer + press)",
+      "diff": "Branching sequences from one form without Zapier glue; routing rules in-tool",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Build a hand-coded routing form for 1 DW vendor (e.g., trade vs consumer vs designer) — measure split rate + sequence completion vs their current flat form" },
+        { "step": "MVP", "what": "Form builder with IF/ELSE branches → each branch maps to a different MailerLite/Resend audience + sequence. No-code, single form embed everywhere" },
+        { "step": "DISTRIBUTION", "what": "Webflow + Framer integration partner launch — every 'one-size-fits-all' form on those platforms is a candidate" }
+      ]
+    },
+    {
+      "idx": 10, "name": "SunsetList", "node_id": "1:128",
+      "angle": "Anti-bloat list hygiene as SaaS",
+      "target": "Anyone whose list >5k and going stale",
+      "diff": "Auto-unsubs 12mo non-openers, weekly list-health score, protects sender rep — the opposite of CC's incentives",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Pull 5 friend lists, calculate health score (open rate, bounce rate, 12mo-no-open %) — show before/after if we auto-pruned the dead 30%. Cost vs ESP-tier savings" },
+        { "step": "MVP", "what": "Connect your ESP API → weekly cron prunes 12mo non-openers (after 1 win-back attempt) + emails publisher a health-score PDF. $29/mo flat" },
+        { "step": "DISTRIBUTION", "what": "Pitch directly to ESP CSMs — 'we save your customer's deliverability so they don't churn'. Affiliate cut from saved-customer LTV" }
+      ]
+    },
+    {
+      "idx": 11, "name": "DearPast", "node_id": "10:2",
+      "angle": "Time-shifted email — schedule sends years out",
+      "target": "Insurance/SaaS renewals, milestone businesses, parents, alumni offices",
+      "diff": "Sends scheduled 1–10 years out with Stripe-held payment; refunded if canceled. Built-in time-capsule narrative for B2C, renewal-reminder narrative for B2B",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Run a Notion landing page selling '5-year future email — $25, payment held in escrow'. Measure conversion + 10 customer interviews for use cases" },
+        { "step": "MVP", "what": "Stripe escrow → AWS Step Functions wait state with up-to-10-year timer → SES send on fire date. Refund button live until 24h before send" },
+        { "step": "DISTRIBUTION", "what": "TikTok + Reels organic — 'I emailed myself 5 years ago, here's what I said' POV format. Then partner with parent influencers + alumni newsletters" }
+      ]
+    },
+    {
+      "idx": 12, "name": "InboxStudio", "node_id": "10:16",
+      "angle": "Newsletter → auto-published podcast",
+      "target": "Substack/Beehiiv writers wanting audio reach without recording",
+      "diff": "Every send auto-converts to a 2-min audio episode pushed to Spotify/Apple/Overcast. Zero recording, same content, two formats",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Manually audio-narrate 5 friend newsletters via ElevenLabs cloned voice for 4 weeks. Measure listener growth + writer willingness to pay $50/mo" },
+        { "step": "MVP", "what": "Connect any RSS/email feed → ElevenLabs voice clone (or stock voice) → ffmpeg + intro/outro → auto-publish to Spotify/Apple via Anchor-like API. $30/mo per show" },
+        { "step": "DISTRIBUTION", "what": "Substack import-flow partnership — every Substack onboarding offers 'turn this into a podcast too' upsell. Revenue share with Substack" }
+      ]
+    },
+    {
+      "idx": 13, "name": "EmailDM", "node_id": "10:30",
+      "angle": "Persistent DM thread overlay on B2B email",
+      "target": "Solo SDRs, founder-led outbound, small agencies",
+      "diff": "Every email thread becomes a private chat history visible in your dashboard — no CRM, no extension, no recipient install required",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Build a Postmark-inbound mailbox proxy — forward outbound through it, see if 3 friend SDRs find the threaded view 10x better than Gmail search" },
+        { "step": "MVP", "what": "Send mail via our SMTP relay → reply-tracker stitches threads by Message-ID + recipient + topic embedding → dashboard shows chat-style UI per contact" },
+        { "step": "DISTRIBUTION", "what": "Position as 'Linear for cold email'. Charge $19/mo per seat. Launch on r/sales + Pavilion community. Land-and-expand into sales teams" }
+      ]
+    },
+    {
+      "idx": 14, "name": "NewsletterDating", "node_id": "10:44",
+      "angle": "Taste-matched newsletter discovery for readers",
+      "target": "Newsletter junkies + publishers under 5k subs hunting for distribution",
+      "diff": "Reader picks 3 tastes → daily 'For You' digest of newsletter excerpts. Publishers bid for placement. Built-in distribution layer for the long tail",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Manual concierge — 50 readers self-select tastes, we hand-curate daily digest from 200 newsletters for 2 weeks. Measure: 30%+ DAU retention" },
+        { "step": "MVP", "what": "Reader onboarding (3 tastes) → vector-similarity match of newsletter content → daily digest with 5 excerpts → click-through routes back to original. CPC payout to platform" },
+        { "step": "DISTRIBUTION", "what": "Publisher acquisition first — Beehiiv/Substack outreach: 'free distribution to 10k matched readers, $0.10 per click'. Then SEO + TikTok for reader side" }
+      ]
+    },
+    {
+      "idx": 15, "name": "HandwrittenMailer", "node_id": "10:58",
+      "angle": "AI-replicated handwriting video embedded in email",
+      "target": "Donor relations, post-purchase thank-yous, real-estate warm leads, luxury concierge",
+      "diff": "AI clones your handwriting + sends an animated 'writing-in-real-time' video clip embedded as email hero. Spectacle-grade personalization at scale",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Mail 50 'video handwritten thank-yous' from a real Etsy seller after purchase → measure repeat-purchase + review rate vs control" },
+        { "step": "MVP", "what": "Upload 1 handwriting sample → SnapMobi/Calligrapher.AI generates per-recipient writing → ffmpeg renders 6-sec pen-draw video → email embed (or hosted link). $59 for 100 sends" },
+        { "step": "DISTRIBUTION", "what": "Pitch into Shopify post-purchase flow apps + nonprofit donor-relations stacks (Bloomerang, DonorPerfect). $1-2 per send revenue share" }
+      ]
+    },
+    {
+      "idx": 16, "name": "BackChannel", "node_id": "10:72",
+      "angle": "Anti-marketing one-emoji honest response loop",
+      "target": "Founder-led brands tired of fake open-rate metrics",
+      "diff": "Recipients see send list size + reply with one emoji. Sender sees radically honest signal — no surveys, no NPS, no theater",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Add a 'reply with one emoji' CTA to 5 newsletters' next send. Compare emoji-reply count to historical open/click; measure correlation w/ real conversions" },
+        { "step": "MVP", "what": "Each email has unique reply-mailbox per recipient → inbound parser detects single emoji → dashboard shows aggregate sentiment + per-reader fingerprint. Free tier 1k sends, $19/mo unlimited" },
+        { "step": "DISTRIBUTION", "what": "Lean into anti-metrics positioning — IndieHackers + Lenny's Newsletter community. Public-data 'most-loved-emoji' leaderboard as viral hook" }
+      ]
+    },
+    {
+      "idx": 17, "name": "MailerFork", "node_id": "10:86",
+      "angle": "GitHub-style forks + commentary for newsletters",
+      "target": "Niche-newsletter writers, community builders, group-house chat overflow",
+      "diff": "Subscribers fork your send, add their own paragraph, and forward to their own list. Provenance preserved, attribution flows back",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Manually run a 'forkable' edition of 3 newsletters where readers reply with their take + we re-publish curated forks. Measure: organic list growth from forks" },
+        { "step": "MVP", "what": "Newsletter sends include a 'Fork this' button → forks stored as branches with original locked → forwarded forks retain attribution footer + back-link. Free, monetized by hosting top forks" },
+        { "step": "DISTRIBUTION", "what": "Launch with a high-profile writer doing a 'fork chain' experiment — measurable how-many-readers-became-publishers metric for HN/Twitter post" }
+      ]
+    },
+    {
+      "idx": 18, "name": "GhostList", "node_id": "10:100",
+      "angle": "Sign-In-With-Apple style relay — sender never sees real email",
+      "target": "Privacy-paranoid creators, GDPR-strict B2B EU, regulated industries",
+      "diff": "Recipients sign up via Apple/Google/anonymous relay. Sender only sees opaque per-list ID. List leak = structurally impossible",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Hand-build a relay for one privacy-conscious newsletter (1k subs). Measure: opt-in lift from 'we never see your email' headline" },
+        { "step": "MVP", "what": "Hosted relay infra — each list gets a domain like list-XYZ.gst.email. Reader subscribes via Apple Hide-My-Email/Firefox Relay/our relay → all sends flow through. Sender sees only IDs" },
+        { "step": "DISTRIBUTION", "what": "Position alongside Privacy Badger / DuckDuckGo Privacy Pro. Charter list — 'first 100 privacy-first creators' onboarded free for life w/ social proof" }
+      ]
+    },
+    {
+      "idx": 19, "name": "TippableNewsletter", "node_id": "10:114",
+      "angle": "Anti-paywall — free forever, opt-in $1-$20 tips per send",
+      "target": "Free-for-all writers who hate paywalls but want income",
+      "diff": "Every send has a Stripe-powered tip button ($1/$5/$20). Free content, opt-in patronage, 90% to writer. No subs, no churn",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Add Stripe Tip button to 5 friend newsletters for 4 weeks. Measure: $/send vs control (no button). Hypothesis: at 1k subs, $5-$50/send sustainable" },
+        { "step": "MVP", "what": "Stripe Connect onboarding for writers → embed code generates per-send tip button → tips routed direct to writer, we take 10% via app fee" },
+        { "step": "DISTRIBUTION", "what": "Recruit from Substack writers who *don't* paywall — likely 70% of their base. 'Get paid without paywalling' tagline. Partner with Stripe for co-marketing" }
+      ]
+    },
+    {
+      "idx": 20, "name": "InboxHeartbeat", "node_id": "10:128",
+      "angle": "Tiny recurring 30-word brand-presence email",
+      "target": "Wedding planners, therapists, lawyers, consultants — relationship businesses",
+      "diff": "Every 9 days a 30-word, one-image, no-CTA email. Designed to be loved, not deleted. Inversion of the send-more ESP norm",
+      "action_items": [
+        { "step": "VALIDATE", "what": "Run for 60 days across 5 relationship-business contacts. Measure: list-retention vs benchmark + qualitative 'I'd never unsubscribe' replies" },
+        { "step": "MVP", "what": "Onboarding asks one question — 'what's your brand voice in 3 words?' → AI generates 12 weeks of 30-word sends → user approves batch → autopilots forever. $19/mo" },
+        { "step": "DISTRIBUTION", "what": "Pitch into Honeybook + Dubsado (wedding/freelancer CRMs) as integration. 'The retention email your CRM never thought of'" }
+      ]
+    }
+  ]
+}
diff --git a/generate-daily.sh b/generate-daily.sh
new file mode 100755
index 0000000..c77461f
--- /dev/null
+++ b/generate-daily.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+# generate-daily.sh — produce 20 fresh contact-mailer product ideas via claude CLI.
+# Run by launchd com.steve.contact-mailer-daily at 08:00 PT daily.
+# Triggered manually: ./generate-daily.sh [YYYY-MM-DD]
+set -euo pipefail
+
+ROOT="$HOME/Projects/contact-mailer-ideas"
+DATE="${1:-$(date +%Y-%m-%d)}"
+OUT="$ROOT/batches/${DATE}.json"
+LOG="$ROOT/logs/${DATE}.log"
+mkdir -p "$ROOT/batches" "$ROOT/logs"
+
+# Per MEMORY: NEVER use Anthropic API key — strip both before claude CLI
+unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN
+
+# Idempotent: skip if today's batch already exists
+if [ -f "$OUT" ]; then
+  echo "$(date -Iseconds) batch ${DATE} already exists, skipping" >> "$LOG"
+  exit 0
+fi
+
+# Existing idea names to avoid duplicates
+EXISTING=$(jq -r '.ideas[].name' "$ROOT"/ideas.json "$ROOT"/batches/*.json 2>/dev/null \
+  | sort -u | tr '\n' ',' | sed 's/,$//')
+
+PROMPT="You are generating 20 NEW product ideas in the 'contact mailer' / email-marketing space (Constant Contact / Mailchimp / Beehiiv / Lemlist adjacent).
+
+HARD RULES:
+- 20 ideas, distinct from each other and from existing names: ${EXISTING}
+- Each idea must be a real PRODUCT (not a feature), shippable by a 2-person team in ≤8 weeks.
+- Mix angles: AI-native, anti-features, vertical (B2B trade / nonprofit / regulated industries), hardware-adjacent, time-shifted, monetization, privacy-first, network-effect, hybrid offline+digital. Avoid me-too 'better Mailchimp'.
+
+OUTPUT FORMAT: pure JSON only, no markdown, no commentary. Shape exactly:
+{
+  \"ideas\": [
+    {
+      \"name\": \"ShortPascalCase\",
+      \"angle\": \"one-line product angle\",
+      \"target\": \"who buys this\",
+      \"diff\": \"what makes it different in one sentence\",
+      \"headline\": \"a 4-8 word hero headline\",
+      \"action_items\": [
+        {\"step\":\"VALIDATE\",\"what\":\"one concrete test to de-risk before building\"},
+        {\"step\":\"MVP\",\"what\":\"minimum shippable scope + pricing hint\"},
+        {\"step\":\"DISTRIBUTION\",\"what\":\"where to find first 10 customers\"}
+      ]
+    }
+    // ... 20 items
+  ]
+}
+
+Color palette suggestion is NOT needed. The downstream tool assigns colors.
+Return ONLY the JSON object. No prose."
+
+echo "$(date -Iseconds) running claude generator for ${DATE}" >> "$LOG"
+
+# Try claude CLI; capture stdout
+RAW="$(claude -p "$PROMPT" 2>>"$LOG" || true)"
+
+# Extract JSON: prefer first {...} block; fall back to fenced code block if claude wrapped it
+JSON=$(printf '%s' "$RAW" | python3 -c "
+import sys, re, json
+s = sys.stdin.read()
+# Try fenced code block first
+m = re.search(r'\`\`\`(?:json)?\s*(\{.*?\})\s*\`\`\`', s, re.DOTALL)
+if m:
+    s = m.group(1)
+# Find first top-level JSON object
+depth = 0
+start = -1
+for i, ch in enumerate(s):
+    if ch == '{':
+        if depth == 0: start = i
+        depth += 1
+    elif ch == '}':
+        depth -= 1
+        if depth == 0 and start >= 0:
+            try:
+                obj = json.loads(s[start:i+1])
+                if 'ideas' in obj: print(json.dumps(obj)); sys.exit(0)
+            except: pass
+sys.exit(1)
+" 2>>"$LOG" || true)
+
+if [ -z "$JSON" ]; then
+  echo "$(date -Iseconds) FAILED to parse claude output. raw saved to ${LOG}.raw" >> "$LOG"
+  printf '%s' "$RAW" > "${LOG}.raw"
+  exit 1
+fi
+
+COUNT=$(printf '%s' "$JSON" | jq '.ideas | length')
+if [ "$COUNT" -lt 10 ]; then
+  echo "$(date -Iseconds) too few ideas: ${COUNT}, aborting" >> "$LOG"
+  exit 1
+fi
+
+# Compose full batch document with batch metadata + index renumbering
+printf '%s' "$JSON" | jq --arg date "$DATE" --arg ts "$(date -Iseconds)" '{
+  batch: ("contact-mailer-" + ($date | gsub("-"; ""))),
+  batch_date: $date,
+  vertical: "contact-mailer / email-marketing (Constant Contact-adjacent)",
+  authority: "ideate-only",
+  generated_at: $ts,
+  generator: "claude-cli daily",
+  ideas: [.ideas | to_entries[] | {
+    idx: (.key + 1),
+    name: .value.name,
+    angle: .value.angle,
+    target: .value.target,
+    diff: .value.diff,
+    headline: .value.headline,
+    action_items: .value.action_items
+  }]
+}' > "$OUT"
+
+# Append jsonl seeds (one per idea) so the existing ideas.jsonl ledger sees them
+jq -r --arg date "$DATE" '.ideas[] | {batch: ("contact-mailer-" + ($date | gsub("-"; ""))), batch_date: $date, tick: 0, idx: .idx, name: .name, headline: .headline, angle: .angle, authority: "ideate-only"} | tojson' "$OUT" \
+  >> ~/.claude/skills/idea-loop/data/ideas.jsonl
+
+echo "$(date -Iseconds) wrote ${OUT} with ${COUNT} ideas" >> "$LOG"
+
+# Sync to prod
+"$ROOT/sync-to-prod.sh" >> "$LOG" 2>&1 || echo "$(date -Iseconds) sync-to-prod failed" >> "$LOG"
+
+echo "$(date -Iseconds) done" >> "$LOG"
diff --git a/sync-to-prod.sh b/sync-to-prod.sh
index 5b5ff80..a5fef3d 100755
--- a/sync-to-prod.sh
+++ b/sync-to-prod.sh
@@ -3,8 +3,11 @@
 # so https://ideas.agentabrams.com reflects the latest yolo-loop state.
 # Called automatically after every wakeup tick.
 set -euo pipefail
+ssh -q root@45.61.58.125 "mkdir -p /root/Projects/contact-mailer-ideas/data/batches"
 scp -q /Users/stevestudio2/Projects/contact-mailer-ideas/ideas.json \
     root@45.61.58.125:/root/Projects/contact-mailer-ideas/data/ideas.json
 scp -q /Users/stevestudio2/.claude/skills/idea-loop/data/ideas.jsonl \
     root@45.61.58.125:/root/Projects/contact-mailer-ideas/data/ideas.jsonl
+rsync -az --delete /Users/stevestudio2/Projects/contact-mailer-ideas/batches/ \
+    root@45.61.58.125:/root/Projects/contact-mailer-ideas/data/batches/
 echo "synced $(date -Iseconds)"
diff --git a/viewer/server.js b/viewer/server.js
index 92e5662..ee1b407 100644
--- a/viewer/server.js
+++ b/viewer/server.js
@@ -7,6 +7,7 @@ const PORT = parseInt(process.env.PORT, 10) || 9773;
 const BATCH = process.env.BATCH || 'contact-mailer-cc-01';
 const IDEAS_JSON = process.env.IDEAS_JSON || '/Users/stevestudio2/Projects/contact-mailer-ideas/ideas.json';
 const IDEAS_JSONL = process.env.IDEAS_JSONL || '/Users/stevestudio2/.claude/skills/idea-loop/data/ideas.jsonl';
+const BATCHES_DIR = process.env.BATCHES_DIR || '/Users/stevestudio2/Projects/contact-mailer-ideas/batches';
 
 const COLORS = {
   AddressAI:          { c: '#0EA5E9', a: '#0369A1' },
@@ -31,14 +32,56 @@ const COLORS = {
   InboxHeartbeat:     { c: '#FB7185', a: '#BE123C' }
 };
 
+function listBatches() {
+  const batches = [];
+  try {
+    for (const f of fs.readdirSync(BATCHES_DIR).sort().reverse()) {
+      if (!f.endsWith('.json')) continue;
+      try {
+        const doc = JSON.parse(fs.readFileSync(path.join(BATCHES_DIR, f), 'utf8'));
+        batches.push({
+          file: f,
+          date: doc.batch_date || f.replace(/\.json$/, '').slice(0,10),
+          batch: doc.batch || f.replace(/\.json$/, ''),
+          count: (doc.ideas || []).length,
+          generator: doc.generator || 'manual'
+        });
+      } catch (e) {}
+    }
+  } catch (e) {}
+  return batches;
+}
+
+function loadBatch(date) {
+  const batches = listBatches();
+  let target = batches.find(b => b.date === date) || batches[0];
+  if (!target) return { meta: {}, byIdx: {}, allTicks: [], batches: [] };
+  const doc = JSON.parse(fs.readFileSync(path.join(BATCHES_DIR, target.file), 'utf8'));
+  const ideas = doc.ideas || [];
+  let lines = [];
+  try { lines = fs.readFileSync(IDEAS_JSONL, 'utf8').split('\n').filter(Boolean); } catch (e) {}
+  const ticks = lines
+    .map(l => { try { return JSON.parse(l); } catch (e) { return null; } })
+    .filter(t => t && t.batch === doc.batch);
+  const byIdx = {};
+  for (const idea of ideas) byIdx[idea.idx] = { ...idea, ticks: [] };
+  for (const t of ticks) {
+    if (t.tick === 0) continue;
+    if (byIdx[t.idx]) byIdx[t.idx].ticks.push(t);
+  }
+  for (const k in byIdx) byIdx[k].ticks.sort((a,b) => b.tick - a.tick);
+  return { meta: doc, byIdx, allTicks: ticks.sort((a,b) => (b.tick||0)-(a.tick||0)), batches, selected: target };
+}
+
 function loadIdeas() {
+  // Default: latest batch from batches/. Fallback to legacy ideas.json if no batches.
+  const batches = listBatches();
+  if (batches.length) return loadBatch(batches[0].date);
   const json = JSON.parse(fs.readFileSync(IDEAS_JSON, 'utf8'));
   const ideas = json.ideas;
   let lines = [];
   try { lines = fs.readFileSync(IDEAS_JSONL, 'utf8').split('\n').filter(Boolean); } catch (e) {}
-  const ticks = lines
-    .map(l => { try { return JSON.parse(l); } catch (e) { return null; } })
-    .filter(t => t && t.batch === BATCH);
+  const ticks = lines.map(l => { try { return JSON.parse(l); } catch (e) { return null; } }).filter(t => t && t.batch === BATCH);
   const byIdx = {};
   for (const idea of ideas) byIdx[idea.idx] = { ...idea, ticks: [] };
   for (const t of ticks) {
@@ -46,10 +89,14 @@ function loadIdeas() {
     if (byIdx[t.idx]) byIdx[t.idx].ticks.push(t);
   }
   for (const k in byIdx) byIdx[k].ticks.sort((a,b) => b.tick - a.tick);
-  return { meta: json, byIdx, allTicks: ticks.sort((a,b) => (b.tick||0)-(a.tick||0)) };
+  return { meta: json, byIdx, allTicks: ticks.sort((a,b) => (b.tick||0)-(a.tick||0)), batches: [], selected: null };
 }
 
-app.get('/api/state', (req, res) => res.json(loadIdeas()));
+app.get('/api/state', (req, res) => {
+  const date = req.query.date;
+  res.json(date ? loadBatch(date) : loadIdeas());
+});
+app.get('/api/batches', (req, res) => res.json(listBatches()));
 
 app.get('/', (req, res) => {
   res.type('html').send(`<!doctype html>
@@ -107,6 +154,9 @@ footer{padding:20px 48px 40px;color:#5a5a62;font-size:12px;text-align:center}
   <h1>Contact Mailer · 10 ideas</h1>
   <span class="meta">batch <code style="color:#9aa3b2">contact-mailer-cc-01</code> · ideate-only loop · refines one idea every ~30 min</span>
   <span class="right">
+    <label style="margin-right:14px;color:#7a7a82;font-size:12px">batch
+      <select id="batchSel" style="background:#13131a;color:#e8e8ea;border:1px solid #2a2a31;padding:5px 8px;border-radius:6px;font-size:12px;margin-left:6px"></select>
+    </label>
     <a href="https://www.figma.com/design/1hHZQ0xggOqzVuPIB0rD3r" target="_blank">Figma file ↗</a>
     &nbsp;·&nbsp;
     <span class="refresh" id="rfx">auto-refresh 15s</span>
@@ -116,14 +166,24 @@ footer{padding:20px 48px 40px;color:#5a5a62;font-size:12px;text-align:center}
 <footer>port 9773 · localhost only · source ~/.claude/skills/idea-loop/data/ideas.jsonl</footer>
 <script>
 const COLORS = ${JSON.stringify(COLORS)};
+let currentDate = null;
+async function loadBatches(){
+  const r = await fetch('/api/batches'); const list = await r.json();
+  const sel = document.getElementById('batchSel');
+  sel.innerHTML = list.map(b => '<option value="'+b.date+'">'+b.date+' · '+b.count+' ideas'+(b.generator!=='manual'?' (auto)':'')+'</option>').join('');
+  if (!currentDate && list.length) currentDate = list[0].date;
+  sel.value = currentDate || '';
+  sel.onchange = () => { currentDate = sel.value; load(); };
+}
 async function load(){
-  const r = await fetch('/api/state'); const j = await r.json();
+  const url = currentDate ? '/api/state?date='+encodeURIComponent(currentDate) : '/api/state';
+  const r = await fetch(url); const j = await r.json();
   const grid = document.getElementById('grid'); grid.innerHTML = '';
   const ideas = Object.values(j.byIdx).sort((a,b)=>a.idx-b.idx);
   for (const i of ideas){
     const last = i.ticks[0];
     const headline = (last && last.new_headline) || (last && last.replacement && last.replacement.headline) || i.headline_seed || '—';
-    const c = COLORS[i.name] || {c:'#888',a:'#444'};
+    const c = colorFor(i.name);
     const scores = last && last.scores ? last.scores : null;
     const verdict = last ? last.verdict : '';
     const card = document.createElement('div');
@@ -168,8 +228,16 @@ function renderTicks(ts){
     </div>\`).join('') + '</div>';
 }
 function escapeHtml(s){return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
-load();
-setInterval(load, 15000);
+function colorFor(name){
+  if (COLORS[name]) return COLORS[name];
+  // Deterministic fallback for newly-generated ideas without a preset palette
+  let h = 0;
+  for (let i=0;i<name.length;i++) h = (h*31 + name.charCodeAt(i)) & 0xffff;
+  const hue = h % 360;
+  return { c: 'hsl('+hue+',70%,55%)', a: 'hsl('+hue+',70%,30%)' };
+}
+loadBatches().then(load);
+setInterval(() => { loadBatches(); load(); }, 15000);
 </script>
 </body></html>`);
 });

← 2d93e28 ideas 11-20: time-capsule, podcast, DM, taste-graph, handwri  ·  back to Contact Mailer Ideas  ·  viewer banner: today's batch + countdown to next 5 AM auto-b fbef505 →