End-to-end flow for the Castellan outreach system. localhost:3000 is your command center — funnel stats, queue review, template editing, pipeline buttons, conversations.
Where things run
outreach.castellan.so = engine room. Cron endpoints (GitHub Actions hits them every 15 min), click-tracking redirect at r.castellan.so, Gmail send. You don't visit it.
localhost:3000 = cockpit. A launchd agent auto-starts it on login (./scripts/install-dev-server-launchd.sh).
Vault vs. database
Vault (~/Developer/discovery-vault/) = thinking layer. Research, synthesis, interaction transcripts, T1 templates A/B/C/D. Authoritative for who is in the pipeline.
Database (Postgres on Neon) = operational layer. Sends, replies, scheduled_for, statuses. Authoritative for what the system did. Don't hand-edit pipeline state.
Handoff: vault → DB via build:dossiers + draft:owner-emails. DB → vault via sync:vault (read-only audit trail).
Before anything else can happen, you need data inthe vault. This is the "run periodically" layer — none of these are dashboard buttons because they're slow, use paid APIs, or require human review of the output.
npm run scrape:zillow. Iterates the PMC list, searches Zillow for each, parses the listings, writes/updates discovery-vault/leads/<pmc>__long-beach-ca.md. Uses Browserbase under the hood (anti-bot resilient). Run weekly-ish to refresh DOM counts and find new vacancies.npm run backfill:tech-platforms:kimi (LLM-driven from PMC website) or :browserbase (mystery-shopper inquiry to detect what auto-responds). Outputs pms_detected in lead frontmatter — used as an ICP signal (Tenant Turner / Property Meld / etc. = mid-market PM, our wedge).npm run find:owners:batchdata + find:owner-contacts:batchdata (paid; primary path). Or fallbacks: find:propstream:skip-trace, find:apify:skip-trace, find:la-assessor. Writes owner_contacts[] into each lead file.npm run scrape:irem-socal → npm run match:irem-prospects. Use occasionally to find PMCs we haven't scraped yet.discovery-vault/leads/<slug>.md with at minimum: name, website, principals[]. Then re-run the scrape + skip-trace commands to populate the rest.All of these write to the vault filesystem (markdown files). The dashboard's Coverage section reads from those files to show the funnel + per-PMC engagement.
Per-PMC lead files live at discovery-vault/leads/. Each has YAML frontmatter (PMC metadata + owner_contacts[]) and prose body (listings, principals, notes).
Hand-touch when:
do_not_contact_overall: true on a contactowner_contacts[]principals[] after a discovery calldiscovery-vault/interactions/Click Rebuild dossiers on the home page (or run npm run build:dossiers). Reads each lead file, dedupes by owner, picks the longest-DOM listing as the primary hook, classifies archetype (A/B/C/D), scores quality, ranks for first-batch selection. Writes per-owner dossiers to discovery-vault/leads/_outreach_drafts/.
Multi-property handling: one dossier per owner even if they have multiple stale units. The body references the longest-DOM unit + alludes to "any of your other stale units" generically.
Operator overrides preserved: if you flip selected_for_first_batch in /dossiers (or directly in the dossier file), rebuilds preserve that. Same for primary_template.
Click Queue T1 drafts on the home page (or run npm run draft:owner-emails). For each first-batch dossier: picks the right vault template (B/D), upserts a prospect row, inserts a T1 send with status=queued.
Idempotent — won't double-insert. Refresh queued bodies button (or --refresh-queued flag) updates body+subject on already-queued T1s, useful after editing the vault template.
T2/T3/T4 are NOT created here. The cron creates them after T1 sends, using the sequence_steps defaults editable at /campaigns/lb-owner.
Go to /queue. Per-draft actions:
Bulk: check rows → Approve N or Schedule…. Bulk schedule auto-staggers 30s apart.
⚠ Drafts queued >48h ago show a stale-warning banner with a Zillow search link — verify the listing is still up before approving.
⚠ Bulk-approving 2+ owners under the same PMC for the same day creates a "cluster" the PMC principal will notice. The picker warns you. Spread across days using Schedule….
GitHub Actions runs cron-send-approved every 15 min, calls Gmail for any scheduled_for ≤ NOW()approved row. After T1 sends, the cron auto-creates T2 (+3d), T3 (+7d), T4 (+18d) using the campaign's sequence_steps defaults + ±60 min jitter.
cron-poll-replies runs every 15 min, polls Gmail inbox, classifies replies (keyword-only — catches bounces + unsubscribes; everything else stays null until the LLM classifier picks them up).
LLM reply classifier (Claude via your Max plan, not the API): a launchd job runs classify:replies every 30 min on your MacBook, classifies any null replies. Install with ./scripts/install-classify-launchd.sh. The dashboard also has a manual Classify replies button.
View threaded replies at /conversations. Each reply has a dropdown for manual reclassification — useful when the LLM gets one wrong. Picking unsubscribe sets the prospect to paused (sequence never resumes).
draft:owner-emails -- --refresh-queuedto push to already-queued T1s. Already-sent T1s don't change.--refresh-queued).When a reply turns into a real conversation, move the lead from discovery-vault/leads/ to discovery-vault/companies/ with the appropriate pipeline stage. Update discovery-vault/pipeline.md. The dashboard auto-pauses sends once the prospect's status is replied or paused, so no DB action needed.
castellan.so is new. SPF/DKIM/DMARC are configured (DMARC at p=none). Track inbox placement on first batches — send a test to your own gmail/yahoo/outlook before scaling. DMARC tighten review on the calendar for 2026-05-21.sync:vault to push DB state back into the vault for audit visibility.nextBusinessMorningPT only skips Sat/Sun. Memorial/Labor Day etc. fire as normal — manually push out sends that land on a holiday.main auto-deploys.workflow_dispatch if you want to fire send-approved out of band.dmarc@castellan.so for aggregate reports.stephen@castellan.so via Google Workspace. Refresh token in web/.env.local + Vercel env vars.CRON_SECRET: three places to sync — Vercel prod env, GitHub Actions secret, local web/.env.local. Ask Claude Code to do it; it knows the sequence..github/workflows/cron-send-approved.yml / cron-poll-replies.yml on main.