Operator guide

One console, multiple ventures. Campaigns are the unit of everything: each row carries its own sequence, reply framing, and channel config (email / calls / postcards / mail templates). Shared code stays vertical-neutral.

Where things run

outreach.castellan.so = the daily console (this app on Vercel): queues, conversations, campaigns, recovery. r.castellan.so serves click-tracking redirects.

Scheduling: a launchd agent on the Mac Mini fires the two 15-minute crons (send-approved, poll-replies) — GitHub Actions is only a backup tick (its 15-min schedule really fires every 1-2h) plus the daily jobs (listing refresh, sent-folder import, call sheet).

localhost:3000= the same app plus vault-backed extras (Dossiers, PMCs, Coverage, pipeline buttons) that need the discovery-vault filesystem on disk. Those sections simply don't appear on the hosted deploy.

1.

Core concepts

  • Campaign — one motion in one vertical (e.g. property-tax-owner-gwinnett-ga). Owns its sequence steps (T1…Tn with delays), sender mailbox, reply framing (reply_context), deadline copy (deadline_at{{days_left}}), and channel_config — which channels are on and how (postcard template + county copy, call queue, call-sheet digest, fulfillment panel).
  • Prospect — a person/entity inside one campaign. Statuses: queued → active → replied / bounced / paused / completed / excluded. Terminal statuses stop the sequence.
  • Send — one email at one step: queued → approved → sending → sent, or failed / skipped. T1 is reviewed by hand; T2+ auto-schedule after each send with jitter.
  • Suppression list — global, cross-campaign do-not-email. Bounces, unsubscribe replies, and manual suppressions land here; every send path checks it.
2.

The daily loop

  1. Home— the "Next" card ranks what needs you (failed sends → unclassified replies → stale drafts → queue review). The send agenda shows what fires per day.
  2. Email queue (/queue) — review drafts: Approve (next business morning 8:27 PT), Schedule…, Edit, Skip, Send now (two-click confirm), or Send without verifying after a separate recipient warning. Follow-up drafts show prior touches inline ("T1 sent Jul 2 · 1 click · no reply yet"). Tabs: Queued / Needs review (deliverability or buying-committee reach) / Approved / Failed (re-queue or dismiss). Actions update the list instantly.
  3. Conversations (/conversations) — every prospect with real activity, threaded. Classify replies (the dropdown cascades to prospect status), send drafted responses, and use the header actions: Resume a paused sequence, Suppress / Unsuppress globally, recovery for bounces.
  4. Channel queues — Calls, Postcards, LinkedIn, for campaigns that enable them (see §3).

⚠ Drafts queued >48h show a stale banner — re-verify details before approving. Launch review groups multiple contacts at the same company: use varied 10–30 minute gaps, send them at the same time, choose who goes first, or edit individual wording. The choice carries into follow-ups, and a company reply can pause the remaining contacts.

3.

Channels are campaign config, not code

campaigns.channel_config (JSONB) decides what each campaign can do. Adding a county or vertical means inserting a campaign row with the right config — no code changes.

  • postcard — enables mail; picks the template (lb_owner, property_tax, property_tax_county + county name/deadline copy). Test mode returns a preview URL; live mail is gated behind typed confirmation, one card at a time.
  • call_queue — campaign appears in Calls alongside its email cadence.
  • call_digest— prospects with a T1 ~2 days old and no reply get compiled into the weekday call-sheet email, with the campaign's angle line + micro-script.
  • fulfillment — shows the appeals panel on the campaign page (property-tax signing/filing pipeline).
  • prerendered_templates — protects hand-curated send bodies from bulk template refresh.
4.

Safety rails (what stops a bad send)

  • Archived campaign = the off-switch. Nothing sends from an archived campaign, ever.
  • DeBounce verification gates every approval and normal Send now. If you independently know an address is real, Send without verifying skips the paid check after a separate warning. Suppression and reply protections still apply.
  • Global suppression list — checked at approve, at claim, and again right before the Gmail call.
  • Daily limit— visible and editable from the campaign's email queue. It applies per sender mailbox; excess approved emails wait for the next PT day automatically. Until a mailbox limit is saved, DAILY_SEND_CAP (default 40) is the fallback.
  • Reply race guard — a reply landing between claim and send cancels the outgoing follow-up.
  • Company reply pause — when enabled in launch review, a reply from one selected contact skips pending emails and follow-ups to the other contacts at that company.
  • CAN-SPAM footer — opt-in (OUTREACH_FOOTER=on + postal address); appends an address line and reply-to-opt-out sentence to every sequence send.
5.

When something breaks

  • Failed sends land in the Failed tab with the error; Re-queue or Dismiss. Transient Gmail errors already retried twice automatically. Errors starting with stale: mean the function died mid-send — check Gmail Sent before re-queueing.
  • Telegram alerts fire on send failures, a revoked Gmail token (re-run npm run gmail:auth), and reaped stale rows.
  • Cron health: /api/health?details=1 shows last success per job; tick logs live on the Mac Mini (~/outreach-cron/tick.stdout.log).
  • Everything else — deploys, env vars, secret rotation, recovery SQL — lives in web/RUNBOOK.md.
6.

Vertical status + per-vertical notes

  • Property tax (GA) — the active motion. Gwinnett live (email + calls + postcards); Fulton, DeKalb, Cobb archived. Appeals fulfillment on the campaign page; deadlines at /tax-deadlines.
  • PM / leasing (Castellan) — paused 2026-07-06. Its top-of-funnel lives in the discovery-vault pipeline (see appendix); the lb-owner campaign and vault pages stay ready for revival.
  • Medicaid LTC — kill test concluded; campaigns archived. The call-digest machinery it used is now campaign config any vertical can enable.

Appendix: the PM vault pipeline (paused)

The PM motion generates prospects from the discovery-vault: scrape Zillow listings per PMC (scrape:zillow), detect tech platforms, skip-trace owners (BatchData), then build:dossiersdraft:owner-emails to queue T1s. All local-only (vault on disk); the Dossiers / PMCs / Coverage pages and home pipeline buttons appear only in local dev. When a reply becomes a real conversation, graduate the lead to discovery-vault/companies/ and update pipeline.md — the vault, not this DB, is the CRM.

Known gaps + things to watch

  • Listing freshness drift (PM motion). T1 drafts snapshot listing details at draft time; the stale-draft banner + manual re-check is the safety net. No auto re-check pre-send.
  • Reply classification is two-stage. The cron only keyword-classifies (bounces, obvious unsubscribes); the rest wait for the local LLM classifier or manual triage in Conversations.
  • Domain reputation. castellan.so is still young; SPF/DKIM/DMARC configured, DMARC at p=none. The daily cap is the main throttle — keep an eye on inbox placement when scaling a new vertical.
  • US holidays not skipped. Scheduling skips weekends only — push out sends that land on a holiday.

External tools (rare use)

  • Vercel deploy logs / env vars: Vercel dashboard. Push to main auto-deploys.
  • Cron run history: Mac Mini tick logs (ssh clawdiabot 'tail -5 ~/outreach-cron/tick.stdout.log') + GitHub Actions for the daily jobs and backup tick.
  • DNS / DMARC / SPF / DKIM: Namecheap DNS; check dmarc@castellan.so for aggregate reports.
  • Gmail send/auth: stephen@castellan.so via Google Workspace. Refresh token in web/.env.local + Vercel env; per-user sender mailboxes via npm run gmail:store-token.
  • Rotate CRON_SECRET: FOUR places — Vercel env, GitHub Actions secret, local web/.env.local, and the Mac Mini's ~/outreach-cron/.env. Ask Claude Code; it knows the sequence.
See home for at-a-glance status and the send agenda, /campaigns for sequences + templates, and Recovery for bounced/stale/excluded pickups.