[object Object]

← back to Rentv 2026

pr-intelligence: mount /api/pr/* + /admin/pr-intelligence shells; 12-page admin workspace (dashboard, research, orgs, people, 3-col review, campaigns, letter builder, inbox, tasks, sources, runs, settings)

762a11ea2da9cf5e16a86dce5d7ce788013caf3a · 2026-07-30 11:24:31 -0700 · Steve Abrams

Files touched

Diff

commit 762a11ea2da9cf5e16a86dce5d7ce788013caf3a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 11:24:31 2026 -0700

    pr-intelligence: mount /api/pr/* + /admin/pr-intelligence shells; 12-page admin workspace (dashboard, research, orgs, people, 3-col review, campaigns, letter builder, inbox, tasks, sources, runs, settings)
---
 public/admin/pr-intelligence/campaigns.html |  82 ++++++++++
 public/admin/pr-intelligence/inbox.html     | 155 +++++++++++++++++++
 public/admin/pr-intelligence/letters.html   | 230 ++++++++++++++++++++++++++++
 public/admin/pr-intelligence/people.html    | 200 ++++++++++++++++++++++++
 public/admin/pr-intelligence/research.html  | 182 ++++++++++++++++++++++
 public/admin/pr-intelligence/review.html    | 216 ++++++++++++++++++++++++++
 public/admin/pr-intelligence/runs.html      |  80 ++++++++++
 public/admin/pr-intelligence/settings.html  | 150 ++++++++++++++++++
 public/admin/pr-intelligence/sources.html   |  45 ++++++
 public/admin/pr-intelligence/tasks.html     |  46 ++++++
 public/backend.html                         |   1 +
 11 files changed, 1387 insertions(+)

diff --git a/public/admin/pr-intelligence/campaigns.html b/public/admin/pr-intelligence/campaigns.html
new file mode 100644
index 00000000..d75a31be
--- /dev/null
+++ b/public/admin/pr-intelligence/campaigns.html
@@ -0,0 +1,82 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Campaigns</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('campaigns', 'Campaigns', 'Target definitions over verified contacts. Enrollment skips suppressed people; drafts generate per-member and go through review → approval → provider draft → explicit send.');
+  const meta = await PR.api('/meta');
+  const CA = meta.states.find((s) => s.key === 'CA');
+
+  c.innerHTML = `
+    <div class="filters">
+      <button class="btn primary" id="new-camp">+ New campaign</button>
+    </div>
+    <div id="list"><span class="spin"></span></div>
+    <div id="detail"></div>`;
+
+  async function load() {
+    const rows = (await PR.api('/campaigns')).rows;
+    document.getElementById('list').innerHTML = rows.length ? `<table class="grid"><thead><tr>
+      <th>Campaign</th><th>Targets</th><th>Status</th><th>Members</th><th>Sent</th><th>Replies</th><th>Created</th><th></th></tr></thead><tbody>` +
+      rows.map((x) => `<tr>
+        <td><b>${PR.esc(x.name)}</b></td>
+        <td style="font-size:12px">${PR.esc(x.state || 'any')} · ${(x.metros || []).join(',') || 'all metros'} · ${(x.organization_types || []).slice(0, 3).join(',') || 'all types'}</td>
+        <td>${PR.statusBadge(x.status)}</td><td>${x.member_count}</td><td>${x.sent_count}</td><td>${x.reply_count}</td>
+        <td title="${PR.esc(x.created_at)}">🕓 ${PR.date(x.created_at)}</td>
+        <td><button class="btn sm" data-open="${x.id}">Open</button></td></tr>`).join('') + '</tbody></table>'
+      : '<div class="empty">No campaigns yet</div>';
+    document.querySelectorAll('[data-open]').forEach((b) => b.addEventListener('click', () => openCamp(Number(b.dataset.open))));
+  }
+
+  async function openCamp(id) {
+    const camp = await PR.api('/campaigns/' + id);
+    document.getElementById('detail').innerHTML = `
+      <div class="panel" style="margin-top:16px">
+        <h3>${PR.esc(camp.name)} ${PR.statusBadge(camp.status)}</h3>
+        <div class="filters">
+          <select id="c-status">${['draft', 'active', 'paused', 'archived'].map((s) => `<option ${s === camp.status ? 'selected' : ''}>${s}</option>`).join('')}</select>
+          <button class="btn sm" id="c-setstatus">Set status</button>
+          <button class="btn sm" id="c-autodraft">Generate drafts for members without one</button>
+          <a class="btn sm" href="/api/pr/export/outreach?campaign_id=${camp.id}">⬇ Export outreach history</a>
+        </div>
+        <h3 style="margin:12px 0 6px">Members (${camp.members.length})</h3>
+        ${camp.members.length ? `<table class="grid"><thead><tr><th>Person</th><th>Org</th><th>Email status</th><th>Lifecycle</th><th>Outreach</th><th></th></tr></thead><tbody>` +
+          camp.members.map((m) => `<tr>
+            <td><a href="/admin/pr-intelligence/people?id=${m.person_id}">${PR.esc(m.full_name)}</a><br><span style="color:var(--muted);font-size:11.5px">${PR.esc(m.exact_title || '')}</span></td>
+            <td>${PR.esc(m.organization_name || '')}</td>
+            <td>${PR.verifBadge(m.email_verification_status)}</td>
+            <td>${PR.statusBadge(m.lifecycle_status)}</td>
+            <td>${m.outreach_status ? PR.statusBadge(m.outreach_status) : '—'}</td>
+            <td><button class="btn sm danger" data-rm="${m.person_id}">Remove</button></td></tr>`).join('') + '</tbody></table>'
+          : '<div class="empty">No members — enroll from the People or Organizations pages</div>'}
+      </div>`;
+    document.getElementById('c-setstatus').addEventListener('click', async () => {
+      await PR.guard(() => PR.api('/campaigns/' + id, { method: 'PUT', body: { status: document.getElementById('c-status').value } }), 'Status set');
+      load(); openCamp(id);
+    });
+    document.getElementById('c-autodraft').addEventListener('click', async () => {
+      await PR.guard(() => PR.api('/jobs', { method: 'POST', body: { job_type: 'generate-draft', payload: { campaign_id: id } } }), 'Draft-generation job queued (drafts appear in Letter Builder for review)');
+    });
+    document.querySelectorAll('[data-rm]').forEach((b) => b.addEventListener('click', async () => {
+      await PR.guard(() => PR.api('/campaigns/' + id + '/members/' + b.dataset.rm, { method: 'DELETE' }), 'Removed');
+      openCamp(id);
+    }));
+  }
+
+  document.getElementById('new-camp').addEventListener('click', async () => {
+    const name = prompt('Campaign name (e.g. "CA Phase 1 — LA County brokerages"):'); if (!name) return;
+    const r = await PR.guard(() => PR.api('/campaigns', { method: 'POST', body: { name, state: 'CA', metros: [], organization_types: [] } }), 'Campaign created');
+    load(); openCamp(r.id);
+  });
+
+  load();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/inbox.html b/public/admin/pr-intelligence/inbox.html
new file mode 100644
index 00000000..2f9ab05b
--- /dev/null
+++ b/public/admin/pr-intelligence/inbox.html
@@ -0,0 +1,155 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Inbox</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('inbox', 'Inbox & Replies', 'Shared response center. Replies sync from the email provider; sends always require an explicit click; opt-outs suppress immediately.');
+  const meta = await PR.api('/meta');
+  const prov = meta.email_provider;
+
+  c.innerHTML = `
+    <div class="panel" style="display:flex;gap:12px;align-items:center">
+      <b>Email provider:</b> ${PR.badge(prov.active, prov.active === 'gmail' ? 'green' : 'amber')}
+      <span style="color:var(--muted);font-size:12.5px">${PR.esc(prov.note)}</span>
+      <button class="btn sm" id="sync" style="margin-left:auto">↻ Sync threads</button>
+    </div>
+    <div class="cols2">
+      <div>
+        <div class="panel"><h3>Replies</h3><div id="replies"><span class="spin"></span></div></div>
+        <div class="panel"><h3>Follow-ups due</h3><div id="followups"><span class="spin"></span></div></div>
+        <div class="panel"><h3>Sent / pipeline</h3><div id="pipeline"><span class="spin"></span></div></div>
+      </div>
+      <div id="thread-pane"><div class="panel"><div class="empty">Select a thread</div></div></div>
+    </div>`;
+
+  document.getElementById('sync').addEventListener('click', async () => {
+    const r = await PR.guard(() => PR.api('/inbox/sync', { method: 'POST' }), 'Synced');
+    PR.toast(`Checked ${r.threads_checked} thread(s), ${r.new_replies} new repl(ies)` + (r.errors.length ? ', ' + r.errors.length + ' error(s)' : ''));
+    load();
+  });
+
+  async function load() {
+    const j = await PR.api('/inbox');
+    document.getElementById('replies').innerHTML = j.replies.length ? j.replies.map((m) => `
+      <div class="gatechk" style="cursor:pointer" data-thread="${PR.esc(m.provider_thread_id || '')}" data-mid="${m.id}">
+        <b>${PR.esc(m.full_name || '(unknown)')}</b><span style="color:var(--muted)">${PR.esc(m.organization_name || '')}</span>
+        <span style="flex:1;color:var(--muted);font-size:12px">${PR.esc((m.snippet || '').slice(0, 80))}</span>
+        <span style="color:var(--muted);font-size:11px">${PR.dateShort(m.replied_at)}</span></div>`).join('') : '<div class="empty">No replies yet</div>';
+    document.getElementById('followups').innerHTML = j.followups.length ? j.followups.map((m) => `
+      <div class="gatechk" style="cursor:pointer" data-thread="${PR.esc(m.provider_thread_id || '')}">
+        <b>${PR.esc(m.full_name || '')}</b><span>${PR.esc(m.subject || '')}</span>
+        <span style="margin-left:auto;color:var(--warn);font-size:12px">due ${PR.dateShort(m.follow_up_at)}</span></div>`).join('') : '<div class="empty">Nothing due</div>';
+    const pipe = await PR.api('/outreach?direction=outbound&limit=25');
+    document.getElementById('pipeline').innerHTML = pipe.rows.length ? pipe.rows.map((m) => `
+      <div class="gatechk" style="cursor:pointer" data-thread="${PR.esc(m.provider_thread_id || '')}" data-mid="${m.id}">
+        ${PR.statusBadge(m.status)} <b>${PR.esc(m.full_name || '')}</b>
+        <span style="color:var(--muted);font-size:12px">${PR.esc(m.subject || '')}</span>
+        <span style="margin-left:auto;color:var(--muted);font-size:11px">${PR.dateShort(m.sent_at || m.drafted_at)}</span></div>`).join('') : '<div class="empty">No outreach yet</div>';
+    document.querySelectorAll('[data-thread]').forEach((el) => el.addEventListener('click', () => openThread(el.dataset.thread, el.dataset.mid)));
+  }
+
+  async function openThread(threadId, mid) {
+    let msgs = [];
+    if (threadId) msgs = (await PR.api('/inbox/thread/' + encodeURIComponent(threadId))).messages;
+    else if (mid) { const m = await PR.api('/outreach/' + mid); msgs = [m]; }
+    if (!msgs.length) return;
+    const root = msgs[0];
+    const pane = document.getElementById('thread-pane');
+    pane.innerHTML = `
+      <div class="panel">
+        <h3>${PR.esc(root.subject || '(no subject)')}</h3>
+        <div style="font-size:12.5px;color:var(--muted);margin:0 0 10px">
+          ${PR.esc(root.full_name || '')} · ${PR.esc(root.organization_name || '')}
+          ${root.person_id ? `· <a href="/admin/pr-intelligence/people?id=${root.person_id}">person record →</a>` : ''}</div>
+        <div class="thread">${msgs.map((m) => `
+          <div class="msg ${m.direction === 'inbound' ? 'in' : 'out'}">
+            <div class="mh"><b>${m.direction === 'inbound' ? '← Inbound' : '→ Outbound'}</b> ${PR.statusBadge(m.status)}
+              <span>${PR.date(m.sent_at || m.replied_at || m.created_at)}</span></div>
+            <pre>${PR.esc(m.rendered_text || m.body_preserved || '(html only)')}</pre>
+            ${m.status === 'provider_draft_created' && m.direction === 'outbound' ? `
+              <div class="filters" style="margin-top:8px">
+                <button class="btn sm primary" data-send="${m.id}">📤 Send now (explicit)</button>
+                <span style="font-size:11px;color:var(--muted)">re-checks suppression at send time</span></div>` : ''}
+          </div>`).join('')}</div>
+        <h3 style="margin:14px 0 8px">Reply</h3>
+        <div class="filters">
+          ${[["Thank + confirm added to press list", "Thank you — you're on RENTV's list for {{org}} announcements. We've noted your preferred submission route."],
+             ["Ask for correct PR contact", "Thanks for the note — could you point me to the colleague who handles the media/press list so I reach the right person?"],
+             ["Confirm submission method", "Happy to use whichever channel works best — should releases go to this address, or is there a press inbox you prefer?"],
+             ["Ask about CA/AZ coverage", "We focus on California and Arizona commercial real estate — does your team issue announcements for those markets?"]]
+            .map(([label, text], i) => `<button class="btn sm" data-canned="${i}" data-text="${PR.esc(text)}">${label}</button>`).join('')}
+        </div>
+        <textarea id="reply-text" style="width:100%;min-height:110px;border:1px solid var(--line);border-radius:8px;padding:10px;font:13px inherit" placeholder="Write the reply…"></textarea>
+        <div class="filters" style="margin-top:8px">
+          <button class="btn primary" id="reply-draft">Create provider draft</button>
+          <span style="font-size:11.5px;color:var(--muted)">Creates a draft in ${prov.active}; sending is a separate explicit click.</span>
+        </div>
+        <h3 style="margin:16px 0 8px">Actions</h3>
+        <div class="filters">
+          <button class="btn sm" data-act="interested">Mark interested</button>
+          <button class="btn sm" data-act="not_now">Not now</button>
+          <button class="btn sm" id="act-referral">Add referred contact</button>
+          <button class="btn sm" id="act-task">Add task</button>
+          <button class="btn sm" id="act-wrong">Wrong person</button>
+          <button class="btn sm danger" id="act-optout">Opt out / suppress</button>
+          <button class="btn sm" data-act="closed">Close thread</button>
+        </div>
+      </div>`;
+
+    const lastOut = [...msgs].reverse().find((m) => m.direction === 'outbound');
+    pane.querySelectorAll('[data-canned]').forEach((b) => b.addEventListener('click', () => {
+      document.getElementById('reply-text').value = b.dataset.text.replace('{{org}}', root.organization_name || 'your organization');
+    }));
+    pane.querySelector('#reply-draft').addEventListener('click', async () => {
+      const text = document.getElementById('reply-text').value.trim();
+      if (!text) return PR.toast('Write the reply first', true);
+      if (!threadId) return PR.toast('This message has no provider thread yet (create + send the provider draft first)', true);
+      await PR.guard(() => PR.api('/inbox/reply', { method: 'POST', body: { thread_id: threadId, body_text: text } }), 'Reply draft created in ' + prov.active);
+      openThread(threadId);
+    });
+    pane.querySelectorAll('[data-send]').forEach((b) => b.addEventListener('click', async () => {
+      if (!confirm('Send this message now? This is the explicit send step.')) return;
+      await PR.guard(() => PR.api('/outreach/' + b.dataset.send + '/send', { method: 'POST', body: { confirm: true } }), 'Sent');
+      openThread(threadId, mid);
+    }));
+    pane.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async () => {
+      if (!lastOut) return PR.toast('No outbound message on this thread', true);
+      await PR.guard(() => PR.api('/outreach/' + lastOut.id + '/status', { method: 'POST', body: { status: b.dataset.act } }), 'Marked ' + b.dataset.act);
+      openThread(threadId, mid); load();
+    }));
+    pane.querySelector('#act-referral').addEventListener('click', async () => {
+      if (!lastOut) return;
+      const name = prompt('Referred person\'s full name:'); if (!name) return;
+      const title = prompt('Their title (optional):') || null;
+      const email = prompt('Their email, if given in the reply (optional):') || null;
+      const r = await PR.guard(() => PR.api('/inbox/referral', { method: 'POST', body: { message_id: lastOut.id, referred_name: name, referred_title: title, referred_email: email } }), 'Referral recorded + follow-up task created');
+      location.href = '/admin/pr-intelligence/people?id=' + r.referred_person.id;
+    });
+    pane.querySelector('#act-task').addEventListener('click', async () => {
+      const title = prompt('Task:'); if (!title) return;
+      await PR.guard(() => PR.api('/tasks', { method: 'POST', body: { title, entity_type: 'message', entity_id: (lastOut || root).id } }), 'Task added');
+    });
+    pane.querySelector('#act-wrong').addEventListener('click', async () => {
+      if (!root.person_id) return;
+      await PR.guard(() => PR.api('/people/' + root.person_id, { method: 'PUT', body: { lifecycle_status: 'wrong_person' } }), 'Marked wrong person');
+    });
+    pane.querySelector('#act-optout').addEventListener('click', async () => {
+      if (!confirm('Add this contact to the suppression list (permanent opt-out)?')) return;
+      await PR.guard(() => PR.api('/suppression', { method: 'POST', body: { person_id: root.person_id, email: root.public_work_email, reason: 'opt_out', source: 'reply' } }), 'Suppressed');
+      load();
+    });
+  }
+
+  load();
+  const deepThread = new URLSearchParams(location.search).get('thread');
+  if (deepThread) openThread(deepThread);
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/letters.html b/public/admin/pr-intelligence/letters.html
new file mode 100644
index 00000000..d721dc39
--- /dev/null
+++ b/public/admin/pr-intelligence/letters.html
@@ -0,0 +1,230 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Letter Builder</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('letters', 'Letter Builder', 'Block-based base letter with locked sections, immutable versions, vertical snippets, grounded personalization, and desktop/mobile preview. Nothing here sends email.');
+  let tpl, versionBlocks = [], subject = '', versionsList = [];
+
+  c.innerHTML = `
+    <div class="cols2">
+      <div>
+        <div class="panel">
+          <h3>Base letter <span id="l-vinfo" style="font-weight:400;color:var(--muted);font-size:12px"></span></h3>
+          <div style="margin:0 0 10px"><label style="font-size:12px;color:var(--muted)">Subject template</label>
+            <input id="l-subject" style="width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px"></div>
+          <div class="blocks" id="l-blocks"></div>
+          <div class="filters">
+            <button class="btn sm" id="l-addblock">+ Add block</button>
+            <input id="l-note" placeholder="Change note…" style="flex:1;padding:7px 10px;border:1px solid var(--line);border-radius:8px">
+            <button class="btn sm primary" id="l-save">Save as new version</button>
+          </div>
+          <div id="l-clip"></div>
+        </div>
+        <div class="panel">
+          <h3>Version history & compare</h3>
+          <div id="l-versions"></div>
+        </div>
+        <div class="panel">
+          <h3>Vertical & saved snippets</h3>
+          <div id="l-snippets"><span class="spin"></span></div>
+          <div class="filters" style="margin-top:8px">
+            <button class="btn sm" id="s-add">+ New snippet</button>
+          </div>
+        </div>
+      </div>
+      <div>
+        <div class="panel">
+          <h3>Preview with a real contact</h3>
+          <div class="filters">
+            <input id="p-person" type="number" placeholder="Person id" style="width:110px">
+            <button class="btn sm" id="p-run">Render preview</button>
+            <button class="btn sm" id="p-mode-toggle">📱 Mobile view</button>
+          </div>
+          <div style="font-size:12px;color:var(--muted)" id="p-subject"></div>
+          <div id="p-out" class="letter-preview" style="margin-top:8px"><div class="empty">Enter a person id and render — merge fields fill from the live record + settings; unfilled ones stay visibly {{placeholders}}.</div></div>
+          <div id="p-missing" style="font-size:12px;color:var(--warn);margin-top:6px"></div>
+          <div id="p-citations" style="margin-top:10px"></div>
+        </div>
+        <div class="panel">
+          <h3>Drafts awaiting review</h3>
+          <div id="l-drafts"><span class="spin"></span></div>
+        </div>
+      </div>
+    </div>`;
+
+  async function loadTemplate() {
+    const templates = (await PR.api('/letters/templates')).rows;
+    tpl = templates.find((t) => t.is_base) || templates[0];
+    if (!tpl) { c.innerHTML = '<div class="panel">No base template found — run migrations.</div>'; return; }
+    const versions = await PR.api('/letters/templates/' + tpl.id + '/versions');
+    versionsList = versions.rows;
+    const cur = await PR.api('/letters/versions/' + versionsList[0].id);
+    versionBlocks = (cur.blocks || []).map((b) => ({ ...b }));
+    subject = cur.subject_template || '';
+    document.getElementById('l-subject').value = subject;
+    document.getElementById('l-vinfo').textContent = `v${cur.version} · purpose: ${tpl.purpose ? tpl.purpose.slice(0, 90) + '…' : '(configurable in Settings → letter_main_purpose)'}`;
+    renderBlocks(); renderVersions();
+  }
+
+  function renderBlocks() {
+    const box = document.getElementById('l-blocks');
+    box.innerHTML = versionBlocks.sort((a, b) => (a.order || 0) - (b.order || 0)).map((b, i) => `
+      <div class="blk ${b.locked ? 'locked' : ''}" data-i="${i}">
+        <div class="bh"><b>${PR.esc(b.label || b.key)}</b>
+          <span>${PR.esc(b.key)}</span>
+          ${b.locked ? '<span class="b amber">🔒 locked</span>' : ''}
+          <span style="margin-left:auto"></span>
+          <button class="btn sm" data-act="up" title="Move up">↑</button>
+          <button class="btn sm" data-act="down" title="Move down">↓</button>
+          <button class="btn sm" data-act="lock">${b.locked ? 'Unlock' : 'Lock'}</button>
+          <button class="btn sm danger" data-act="del" ${b.locked ? 'disabled' : ''}>✕</button></div>
+        <textarea data-i="${i}" ${b.locked ? 'readonly' : ''}>${PR.esc(b.content)}</textarea>
+      </div>`).join('');
+    box.querySelectorAll('textarea').forEach((t) => t.addEventListener('input', () => { versionBlocks[Number(t.dataset.i)].content = t.value; }));
+    box.querySelectorAll('button[data-act]').forEach((btn) => btn.addEventListener('click', () => {
+      const i = Number(btn.closest('.blk').dataset.i);
+      const b = versionBlocks[i];
+      if (btn.dataset.act === 'lock') {
+        if (b.locked && !confirm('Unlock "' + b.key + '"? Unlocking is audit-logged on the next save.')) return;
+        b.locked = !b.locked; b._unlocked = !b.locked ? b._unlocked : true;
+      }
+      if (btn.dataset.act === 'del') { if (!confirm('Remove block "' + b.key + '"?')) return; versionBlocks.splice(i, 1); }
+      if (btn.dataset.act === 'up' && i > 0) { const o = b.order; b.order = versionBlocks[i - 1].order; versionBlocks[i - 1].order = o; }
+      if (btn.dataset.act === 'down' && i < versionBlocks.length - 1) { const o = b.order; b.order = versionBlocks[i + 1].order; versionBlocks[i + 1].order = o; }
+      renderBlocks();
+    }));
+    renderClip();
+  }
+
+  function renderClip() {
+    const clip = localStorage.getItem('pr-letter-clip');
+    const el = document.getElementById('l-clip');
+    if (!clip) { el.innerHTML = ''; return; }
+    const j = JSON.parse(clip);
+    el.innerHTML = `<div class="evi" style="border-color:var(--accent)"><b>📎 Evidence clipboard</b> (from ${PR.esc(j.org_name || 'record')} · source #${j.source_id})
+      <div class="x">“${PR.esc((j.excerpt || '').slice(0, 200))}”</div>
+      <button class="btn sm" id="clip-insert">Insert as org-specific snippet</button>
+      <button class="btn sm" id="clip-clear">Dismiss</button></div>`;
+    el.querySelector('#clip-insert').addEventListener('click', async () => {
+      await PR.guard(() => PR.api('/letters/blocks', { method: 'POST', body: {
+        label: 'Org fact: ' + (j.org_name || j.org_id), scope: 'organization', organization_id: j.org_id,
+        content: j.excerpt || '', internal_sources: [j.source_id],
+      } }), 'Saved as an organization-specific snippet (with its internal source)');
+      localStorage.removeItem('pr-letter-clip'); renderClip(); loadSnippets();
+    });
+    el.querySelector('#clip-clear').addEventListener('click', () => { localStorage.removeItem('pr-letter-clip'); renderClip(); });
+  }
+
+  document.getElementById('l-addblock').addEventListener('click', () => {
+    const key = prompt('Block key (e.g. market_paragraph):'); if (!key) return;
+    versionBlocks.push({ key, label: key.replace(/_/g, ' '), content: '', order: versionBlocks.length + 1, locked: false });
+    renderBlocks();
+  });
+  document.getElementById('l-save').addEventListener('click', async () => {
+    const unlock = versionBlocks.filter((b) => b._unlocked).map((b) => b.key);
+    await PR.guard(() => PR.api('/letters/templates/' + tpl.id + '/versions', { method: 'POST', body: {
+      subject_template: document.getElementById('l-subject').value,
+      blocks: versionBlocks.map(({ _unlocked, ...b }) => b),
+      change_note: document.getElementById('l-note').value || null,
+      unlock,
+    } }), 'New version saved');
+    document.getElementById('l-note').value = '';
+    loadTemplate();
+  });
+
+  function renderVersions() {
+    document.getElementById('l-versions').innerHTML = versionsList.map((v) => `
+      <div class="gatechk"><b>v${v.version}</b><span style="color:var(--muted)">${PR.date(v.created_at)} · ${PR.esc(v.created_by || '')}</span>
+        <span style="flex:1;color:var(--muted);font-size:12px">${PR.esc(v.change_note || '')}</span>
+        <button class="btn sm" data-restore="${v.id}">Load</button>
+        ${versionsList[0].id !== v.id ? `<button class="btn sm" data-cmp="${v.id}">Compare→current</button>` : PR.badge('current', 'green')}</div>`).join('');
+    document.querySelectorAll('[data-restore]').forEach((b) => b.addEventListener('click', async () => {
+      const v = await PR.api('/letters/versions/' + b.dataset.restore);
+      versionBlocks = (v.blocks || []).map((x) => ({ ...x })); subject = v.subject_template || '';
+      document.getElementById('l-subject').value = subject;
+      PR.toast('Loaded v' + v.version + ' into the editor — save to make it the new current version (undo = load a prior version)');
+      renderBlocks();
+    }));
+    document.querySelectorAll('[data-cmp]').forEach((b) => b.addEventListener('click', async () => {
+      const j = await PR.api('/letters/compare?a=' + b.dataset.cmp + '&b=' + versionsList[0].id);
+      alert(`v${j.from} → v${j.to}\nAdded: ${j.added.join(', ') || '—'}\nRemoved: ${j.removed.join(', ') || '—'}\nChanged: ${j.changed.join(', ') || '—'}\nSubject changed: ${j.subject_changed}`);
+    }));
+  }
+
+  async function loadSnippets() {
+    const rows = (await PR.api('/letters/blocks')).rows;
+    document.getElementById('l-snippets').innerHTML = rows.length ? rows.map((s) => `
+      <div class="gatechk">${PR.badge(s.scope, s.scope === 'vertical' ? 'blue' : s.scope === 'organization' ? 'purple' : 'gray')}
+        <b>${PR.esc(s.label)}</b><span style="color:var(--muted);font-size:12px">${PR.esc(s.org_type || '')}</span>
+        ${(s.internal_sources || []).length ? `<span class="b green" title="grounded in stored sources">${s.internal_sources.length} src</span>` : ''}
+        <button class="btn sm" style="margin-left:auto" data-edit="${s.id}">Edit</button></div>`).join('') : '<div class="empty">No snippets</div>';
+    document.querySelectorAll('[data-edit]').forEach((b) => b.addEventListener('click', async () => {
+      const s = rows.find((x) => String(x.id) === b.dataset.edit);
+      const content = prompt('Edit snippet content:', s.content); if (content == null) return;
+      await PR.guard(() => PR.api('/letters/blocks', { method: 'POST', body: { ...s, content } }), 'Snippet saved');
+      loadSnippets();
+    }));
+  }
+  document.getElementById('s-add').addEventListener('click', async () => {
+    const label = prompt('Snippet label:'); if (!label) return;
+    const content = prompt('Snippet content ({{merge_fields}} allowed):'); if (content == null) return;
+    await PR.guard(() => PR.api('/letters/blocks', { method: 'POST', body: { label, content, scope: 'generic' } }), 'Snippet created');
+    loadSnippets();
+  });
+
+  // Preview
+  let mobile = false, lastPrev = null;
+  document.getElementById('p-mode-toggle').addEventListener('click', () => { mobile = !mobile; drawPreview(); });
+  document.getElementById('p-run').addEventListener('click', async () => {
+    const pid = Number(document.getElementById('p-person').value);
+    if (!pid) return PR.toast('Enter a person id (People page shows ids in the URL)', true);
+    lastPrev = await PR.guard(() => PR.api('/letters/preview', { method: 'POST', body: {
+      person_id: pid, blocks: versionBlocks, subject_template: document.getElementById('l-subject').value,
+    } }));
+    drawPreview();
+  });
+  function drawPreview() {
+    if (!lastPrev) return;
+    document.getElementById('p-subject').innerHTML = 'Subject: <b style="color:var(--ink)">' + PR.esc(lastPrev.subject) + '</b>';
+    const html = PR.esc(lastPrev.text).replace(/\{\{([a-z0-9_]+)\}\}/gi, '<span class="missing">{{$1}}</span>');
+    document.getElementById('p-out').outerHTML = mobile
+      ? `<div id="p-out" class="mobile-frame"><div class="letter-preview" style="border:none;border-radius:0;font-size:13px">${html}</div></div>`
+      : `<div id="p-out" class="letter-preview">${html}</div>`;
+    document.getElementById('p-missing').textContent = lastPrev.missing.length
+      ? '⚠ Unfilled merge fields (complete them in Settings before this letter can send): ' + lastPrev.missing.join(', ') : '';
+    document.getElementById('p-citations').innerHTML = lastPrev.citations.length
+      ? '<b style="font-size:12px">Internal citations (kept on the admin record, stripped from outgoing email):</b> ' +
+        lastPrev.citations.map((x) => PR.badge('src #' + x.source_id + ' → ' + x.block, 'purple')).join(' ')
+      : '';
+  }
+
+  async function loadDrafts() {
+    const j = await PR.api('/outreach?status=draft_generated&limit=15');
+    const j2 = await PR.api('/outreach?status=needs_review&limit=15');
+    const rows = [...j.rows, ...j2.rows];
+    document.getElementById('l-drafts').innerHTML = rows.length ? rows.map((m) => `
+      <div class="gatechk">${PR.statusBadge(m.status)} <b>${PR.esc(m.full_name || '')}</b>
+        <span style="color:var(--muted)">${PR.esc(m.organization_name || '')}</span>
+        <span style="margin-left:auto"></span>
+        <button class="btn sm" data-approve="${m.id}">Approve</button>
+        <button class="btn sm" data-pd="${m.id}">Provider draft</button></div>`).join('') : '<div class="empty">No drafts awaiting review</div>';
+    document.querySelectorAll('[data-approve]').forEach((b) => b.addEventListener('click', () =>
+      PR.guard(() => PR.api('/outreach/' + b.dataset.approve + '/approve', { method: 'POST' }), 'Approved').then(loadDrafts)));
+    document.querySelectorAll('[data-pd]').forEach((b) => b.addEventListener('click', () =>
+      PR.guard(() => PR.api('/outreach/' + b.dataset.pd + '/provider-draft', { method: 'POST' }), 'Provider draft created — send stays a separate explicit click in the Inbox').then(loadDrafts)));
+  }
+
+  await loadTemplate();
+  loadSnippets();
+  loadDrafts();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/people.html b/public/admin/pr-intelligence/people.html
new file mode 100644
index 00000000..72d766ea
--- /dev/null
+++ b/public/admin/pr-intelligence/people.html
@@ -0,0 +1,200 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — People</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('people', 'People', 'Public business contacts. Exact title AND normalized role are both kept; corrections preserve the extracted original; inferred emails can never be sent.');
+  const meta = await PR.api('/meta');
+  const CA = meta.states.find((s) => s.key === 'CA'), AZ = meta.states.find((s) => s.key === 'AZ');
+
+  let sort = 'priority', dir = 'desc', offset = 0; const limit = 50;
+  const { bar, state } = PR.filters([
+    { key: 'q', placeholder: 'Name, org, title, email, LinkedIn…' },
+    { key: 'department', type: 'select', options: [['', 'Dept: all'], ['communications', 'Communications/PR'], ['marketing', 'Marketing'], ['agency', 'Agency'], ['leadership', 'Leadership'], ['lending', 'Lending'], ['title_escrow', 'Title & escrow'], ['bd', 'Biz dev'], ['other', 'Other']] },
+    { key: 'metro', type: 'select', options: [['', 'Metro: all'], ...CA.metros.map((m) => [m.key, 'CA · ' + m.label]), ...AZ.metros.map((m) => [m.key, 'AZ · ' + m.label])] },
+    { key: 'status', type: 'select', options: [['', 'Status: all']].concat('discovered,needs_verification,verified,stale,wrong_person,left_company,duplicate,suppressed,ready_for_outreach,contacted,replied'.split(',').map((s) => [s, s.replace(/_/g, ' ')])) },
+    { key: 'email_status', type: 'select', options: [['', 'Email: all'], ['manually_verified', 'manually verified'], ['company_site_verified', 'site verified'], ['press_release_verified', 'press verified'], ['corroborated', 'corroborated'], ['inferred', '⚠ inferred'], ['unverified', 'unverified']] },
+    { key: 'linkedin_status', type: 'select', options: [['', 'LinkedIn: all'], ['found_corroborated', 'found + corroborated'], ['found_uncorroborated', 'found, not corroborated'], ['manually_verified', 'manually verified'], ['none', 'none']] },
+    { key: 'outreach_eligible', type: 'select', options: [['', 'Eligible: any'], ['1', 'Outreach-eligible'], ['0', 'Not eligible']] },
+  ], () => { offset = 0; load(); });
+  c.appendChild(bar);
+
+  const actions = document.createElement('div'); actions.className = 'filters';
+  actions.innerHTML = `
+    <button class="btn" id="add-person">+ Add person</button>
+    <button class="btn" id="verify-sel" disabled>Queue verify</button>
+    <button class="btn" id="campaign-sel" disabled>Add to campaign</button>
+    <button class="btn" id="draft-sel" disabled>Generate drafts</button>
+    <a class="btn" id="export" href="#">⬇ Export CSV</a>
+    <span id="selcount" style="color:var(--muted);font-size:12px"></span>`;
+  c.appendChild(actions);
+
+  const selected = new Set();
+  const tbl = PR.table([
+    { key: '_sel', label: '', render: (r) => `<input type="checkbox" data-sel="${r.id}" ${selected.has(r.id) ? 'checked' : ''}>` },
+    { key: 'name', label: 'Person', render: (r) => `<b>${PR.esc(r.full_name)}</b><br><span style="color:var(--muted);font-size:12px">${PR.esc(r.exact_title || '')}</span>` },
+    { key: 'org', label: 'Organization', render: (r) => r.organization_id ? `<a href="/admin/pr-intelligence/organizations?id=${r.organization_id}">${PR.esc(r.organization_name || '')}</a>` : '—' },
+    { key: 'role', label: 'Role', render: (r) => PR.badge(r.normalized_role || 'other', 'blue') + ' ' + PR.badge(r.department || '', 'gray') },
+    { key: '_email', label: 'Email', render: (r) => r.public_work_email ? `${PR.esc(r.public_work_email)}<br>${PR.verifBadge(r.email_verification_status)}` : '—' },
+    { key: '_li', label: 'LinkedIn', render: (r) => r.linkedin_url ? `<a href="${PR.esc(r.linkedin_url)}" target="_blank" rel="noopener noreferrer">profile</a> ${PR.verifBadge(r.linkedin_status)}` : '—' },
+    { key: 'confidence', label: 'Conf', render: (r) => PR.score(r.confidence_score) },
+    { key: 'priority', label: 'Priority', render: (r) => PR.score(r.contact_priority_score) },
+    { key: 'status', label: 'Status', render: (r) => PR.statusBadge(r.lifecycle_status) },
+    { key: 'verified', label: 'Verified', render: (r) => PR.dateShort(r.last_verified_at) },
+  ], { onSort: (s, d2) => { sort = s; dir = d2; load(); }, onRow: (r) => openPerson(r.id) });
+  c.appendChild(tbl.el);
+  const pagerBox = document.createElement('div'); c.appendChild(pagerBox);
+
+  async function load() {
+    const q = new URLSearchParams({ ...Object.fromEntries(Object.entries(state).filter(([, v]) => v)), sort, dir, limit, offset });
+    const j = await PR.api('/people?' + q);
+    tbl.render(j.rows);
+    pagerBox.innerHTML = ''; pagerBox.appendChild(PR.pager(j.total, limit, offset, (o) => { offset = o; load(); }));
+    document.getElementById('export').href = '/api/pr/export/people?' + q;
+    tbl.el.querySelectorAll('[data-sel]').forEach((cb) => cb.addEventListener('change', () => {
+      const id = Number(cb.dataset.sel); cb.checked ? selected.add(id) : selected.delete(id); syncSel();
+    }));
+  }
+  function syncSel() {
+    ['verify-sel', 'campaign-sel', 'draft-sel'].forEach((id) => document.getElementById(id).disabled = !selected.size);
+    document.getElementById('selcount').textContent = selected.size ? selected.size + ' selected' : '';
+  }
+
+  const drawer = PR.drawer();
+  async function openPerson(id) {
+    const p = await PR.api('/people/' + id);
+    const originals = p.original_values || {};
+    const body = drawer.open(`${PR.esc(p.full_name)} ${PR.statusBadge(p.lifecycle_status)} ${p.suppressed ? PR.badge('SUPPRESSED', 'red') : ''}`, `
+      <div class="kv">
+        <dt>Exact title</dt><dd><input id="e-title" value="${PR.esc(p.exact_title || '')}" style="width:100%;padding:5px 8px;border:1px solid var(--line);border-radius:6px">
+          ${originals.exact_title !== undefined ? `<div style="font-size:11px;color:var(--muted)">original extracted: “${PR.esc(originals.exact_title)}”</div>` : ''}</dd>
+        <dt>Normalized role</dt><dd>${PR.badge(p.normalized_role || 'other', 'blue')} · ${PR.esc(p.department || '')} · ${PR.esc(p.seniority || '')}</dd>
+        <dt>Organization</dt><dd>${p.organization_id ? `<a href="/admin/pr-intelligence/organizations?id=${p.organization_id}">${PR.esc(p.organization_name || '')}</a>` : '—'}</dd>
+        <dt>Email</dt><dd><input id="e-email" value="${PR.esc(p.public_work_email || '')}" style="width:60%;padding:5px 8px;border:1px solid var(--line);border-radius:6px">
+          <select id="e-emailstatus" style="padding:5px">${['unverified', 'inferred', 'corroborated', 'company_site_verified', 'press_release_verified', 'manually_verified'].map((s) => `<option ${s === p.email_verification_status ? 'selected' : ''}>${s}</option>`).join('')}</select>
+          ${p.email_verification_status === 'inferred' ? '<div class="b red" style="margin-top:4px">⚠ INFERRED — stored only, never sendable until verified</div>' : ''}
+          ${originals.public_work_email !== undefined ? `<div style="font-size:11px;color:var(--muted)">original: ${PR.esc(originals.public_work_email || '(none)')}</div>` : ''}</dd>
+        <dt>Phone</dt><dd>${PR.esc(p.public_business_phone || '—')}</dd>
+        <dt>LinkedIn</dt><dd>${p.linkedin_url ? `<a href="${PR.esc(p.linkedin_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(p.linkedin_url)}</a>` : '—'} ${PR.verifBadge(p.linkedin_status)}
+          ${p.linkedin_indexed_title ? `<div style="font-size:11px;color:var(--muted)">indexed title (uncorroborated): “${PR.esc(p.linkedin_indexed_title)}”</div>` : ''}</dd>
+        <dt>Location</dt><dd>${PR.esc([p.office_location, p.metro, p.state].filter(Boolean).join(' · ') || '—')}</dd>
+        <dt>Scores</dt><dd>Confidence ${PR.score(p.confidence_score)} · Priority ${PR.score(p.contact_priority_score)}
+          <details style="margin-top:4px"><summary style="cursor:pointer;font-size:12px;color:var(--muted)">components</summary>
+          <pre style="font-size:11px;background:var(--bg);padding:8px;border-radius:6px;overflow:auto">${PR.esc(JSON.stringify(p.score_components || {}, null, 1))}</pre></details></dd>
+        <dt>Outreach eligible</dt><dd>${p.outreach_eligible ? PR.badge('YES', 'green') : PR.badge('no', 'gray')} ${p.suppression_reason ? '· ' + PR.esc(p.suppression_reason) : ''}</dd>
+        <dt>Verified</dt><dd>${PR.verifBadge(p.verification_status)} · ${PR.date(p.last_verified_at)}</dd>
+        <dt>Created</dt><dd title="${PR.esc(p.created_at)}">🕓 ${PR.date(p.created_at)}</dd>
+      </div>
+      ${(p.role_history || []).length ? `<h3 style="margin:10px 0 6px">Role history (preserved)</h3>` +
+        p.role_history.map((h) => `<div class="gatechk"><span style="color:var(--muted)">${PR.dateShort(h.recorded_at)}</span><span>${PR.esc(h.exact_title)}</span></div>`).join('') : ''}
+      <div class="filters" style="margin-top:10px">
+        <button class="btn sm primary" id="e-save">Save corrections</button>
+        <button class="btn sm" id="e-verifyjob">Queue verify (LinkedIn locate)</button>
+        <button class="btn sm" id="e-draft">Generate letter draft</button>
+        <button class="btn sm danger" id="e-suppress">Suppress</button>
+      </div>
+      <div id="e-readiness"></div>
+      <h3 style="margin:14px 0 8px">Source evidence</h3>
+      ${PR.evidenceHtml(p.evidence, { addToLetter: true })}
+      <h3 style="margin:14px 0 8px">Outreach</h3>
+      ${p.outreach.map((m) => `<div class="gatechk">${PR.statusBadge(m.status)} <span>${PR.esc(m.subject || '(no subject)')}</span>
+        <span style="color:var(--muted);margin-left:auto">${PR.dateShort(m.sent_at || m.drafted_at)}</span></div>`).join('') || '<div class="empty">No outreach yet</div>'}
+      <h3 style="margin:14px 0 8px">Audit trail</h3>
+      ${p.recent_activity.slice(0, 12).map((a) => `<div class="gatechk"><span style="color:var(--muted)">${PR.dateShort(a.created_at)}</span>
+        <span>${PR.esc(a.activity)}</span><span style="color:var(--muted)">${PR.esc(a.actor || '')}</span></div>`).join('')}`);
+
+    PR.api('/outreach/readiness/' + id).then((r) => {
+      body.querySelector('#e-readiness').innerHTML = r.ready
+        ? '<div class="b green" style="margin-top:8px">✅ Outreach-ready</div>'
+        : '<div class="evi" style="border-color:var(--warn)"><b>Not outreach-ready:</b> ' + r.reasons.map(PR.esc).join('; ') + '</div>';
+    });
+    body.querySelector('#e-save').addEventListener('click', async () => {
+      await PR.guard(() => PR.api('/people/' + id, { method: 'PUT', body: {
+        exact_title: body.querySelector('#e-title').value,
+        public_work_email: body.querySelector('#e-email').value || null,
+        email_verification_status: body.querySelector('#e-emailstatus').value,
+      } }), 'Saved (original values preserved)');
+      openPerson(id); load();
+    });
+    body.querySelector('#e-verifyjob').addEventListener('click', () =>
+      PR.guard(() => PR.api('/jobs', { method: 'POST', body: { job_type: 'verify-person', payload: { person_id: id } } }), 'Verify job queued'));
+    body.querySelector('#e-draft').addEventListener('click', async () => {
+      const r = await PR.guard(() => PR.api('/outreach/draft', { method: 'POST', body: { person_id: id } }), 'Draft generated');
+      location.href = '/admin/pr-intelligence/letters?message=' + r.message.id;
+    });
+    body.querySelector('#e-suppress').addEventListener('click', async () => {
+      const reason = prompt('Suppression reason (opt_out / manual / complaint):', 'manual'); if (!reason) return;
+      await PR.guard(() => PR.api('/suppression', { method: 'POST', body: { person_id: id, email: p.public_work_email, reason } }), 'Suppressed');
+      drawer.close(); load();
+    });
+    body.querySelectorAll('[data-addletter]').forEach((a) => a.addEventListener('click', (e) => {
+      e.preventDefault();
+      localStorage.setItem('pr-letter-clip', JSON.stringify({ person_id: id, org_id: p.organization_id, org_name: p.organization_name, source_id: Number(a.dataset.addletter), excerpt: a.dataset.excerpt }));
+      PR.toast('Added to letter clipboard');
+    }));
+  }
+
+  document.getElementById('add-person').addEventListener('click', () => {
+    const body = drawer.open('Add person (manual research entry)', `
+      <div class="form">
+        <div><label>Full name *</label><input id="f-name"></div>
+        <div><label>Exact public title</label><input id="f-title"></div>
+        <div><label>Organization id (from Organizations page)</label><input id="f-org" type="number"></div>
+        <div class="frow"><div><label>Public work email</label><input id="f-email"></div>
+          <div><label>Email status</label><select id="f-es"><option>unverified</option><option>inferred</option><option>company_site_verified</option><option>press_release_verified</option><option>manually_verified</option></select></div></div>
+        <div><label>LinkedIn URL (public)</label><input id="f-li"></div>
+        <div><label>Verification URL (the page you checked) *</label><input id="f-src"></div>
+        <div><label>Evidence excerpt</label><textarea id="f-exc"></textarea></div>
+        <button class="btn primary" id="f-save">Save person</button>
+      </div>`);
+    body.querySelector('#f-save').addEventListener('click', async () => {
+      const name = body.querySelector('#f-name').value.trim();
+      const src = body.querySelector('#f-src').value.trim();
+      if (!name) return PR.toast('Name required', true);
+      if (!src) return PR.toast('A verification URL is required', true);
+      await PR.guard(() => PR.api('/people', { method: 'POST', body: {
+        full_name: name, exact_title: body.querySelector('#f-title').value.trim() || null,
+        organization_id: Number(body.querySelector('#f-org').value) || null,
+        public_work_email: body.querySelector('#f-email').value.trim() || null,
+        email_verification_status: body.querySelector('#f-es').value,
+        linkedin_url: body.querySelector('#f-li').value.trim() || null,
+        evidence: { source: { source_type: 'manual', source_name: 'Manual research entry', url: src, short_excerpt: body.querySelector('#f-exc').value.trim() || null, adapter: 'manual' },
+          fields: { organization: 85, exact_title: 85, public_work_email: body.querySelector('#f-email').value ? 85 : 0 } },
+      } }), 'Person saved');
+      drawer.close(); load();
+    });
+  });
+  document.getElementById('verify-sel').addEventListener('click', async () => {
+    for (const id of selected) await PR.api('/jobs', { method: 'POST', body: { job_type: 'verify-person', payload: { person_id: id } } });
+    PR.toast(selected.size + ' verify job(s) queued'); selected.clear(); syncSel();
+  });
+  document.getElementById('campaign-sel').addEventListener('click', async () => {
+    const camps = (await PR.api('/campaigns')).rows;
+    if (!camps.length) return PR.toast('Create a campaign first', true);
+    const cid = prompt('Campaign id:\n' + camps.map((x) => `#${x.id} ${x.name}`).join('\n')); if (!cid) return;
+    const r = await PR.guard(() => PR.api('/campaigns/' + cid + '/enroll', { method: 'POST', body: { person_ids: [...selected] } }));
+    PR.toast(`${r.added.length} enrolled, ${r.skipped.length} skipped`); selected.clear(); syncSel();
+  });
+  document.getElementById('draft-sel').addEventListener('click', async () => {
+    let ok = 0, failReasons = [];
+    for (const id of selected) {
+      try { await PR.api('/outreach/draft', { method: 'POST', body: { person_id: id } }); ok++; }
+      catch (e) { failReasons.push(e.message); }
+    }
+    PR.toast(ok + ' draft(s) generated' + (failReasons.length ? ' · ' + failReasons.length + ' blocked: ' + failReasons[0] : ''), failReasons.length > 0);
+    selected.clear(); syncSel();
+  });
+
+  const deepId = new URLSearchParams(location.search).get('id');
+  await load();
+  if (deepId) openPerson(Number(deepId));
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/research.html b/public/admin/pr-intelligence/research.html
new file mode 100644
index 00000000..231937cc
--- /dev/null
+++ b/public/admin/pr-intelligence/research.html
@@ -0,0 +1,182 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Research</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('research', 'Research', 'Start California discovery waves in the mandated metro order, edit the query matrix, and import data. Arizona controls unlock only after the CA quality gate.');
+  const meta = await PR.api('/meta');
+  const CA = meta.states.find((s) => s.key === 'CA'), AZ = meta.states.find((s) => s.key === 'AZ');
+  const dash = await PR.api('/dashboard');
+  const azUnlocked = dash.arizona.unlocked;
+
+  c.innerHTML = `
+    <div class="cols2">
+      <div>
+        <div class="panel">
+          <h3>Start a discovery run</h3>
+          <div class="form">
+            <div class="frow">
+              <div><label>State (CA first — mandatory order)</label>
+                <select id="r-state"><option value="CA">California (Phase 1)</option>
+                  <option value="AZ" ${azUnlocked ? '' : 'disabled'}>Arizona (Phase 2)${azUnlocked ? '' : ' — 🔒 locked'}</option></select></div>
+              <div><label>Metro</label><select id="r-metro"></select></div>
+            </div>
+            <div><label>Category (optional — narrows the matrix)</label>
+              <select id="r-cat"><option value="">All categories</option>${meta.org_types.map((t) => `<option value="${t.key}">${t.label}</option>`).join('')}</select></div>
+            <button class="btn primary" id="r-start">▶ Queue discovery run</button>
+            <div style="font-size:12px;color:var(--muted)">Runs execute on the worker (<code>npm run pr:worker</code>). Registry sources (FDIC/NCUA) work with no keys; the query matrix needs a search API key (see the adapter panel).</div>
+          </div>
+        </div>
+        <div class="panel">
+          <h3>California metro order (mandated)</h3>
+          ${CA.metros.map((m, i) => `<div class="gatechk"><b>${i + 1}.</b><span>${m.label}</span><span style="color:var(--muted);font-size:12px">${m.counties.join(', ')}</span>
+            <button class="btn sm" style="margin-left:auto" data-metro="${m.key}">Queue</button></div>`).join('')}
+        </div>
+        <div class="panel">
+          <h3>Arizona order ${azUnlocked ? PR.badge('unlocked', 'green') : PR.badge('🔒 locked until CA gate', 'amber')}</h3>
+          ${AZ.metros.map((m, i) => `<div class="gatechk" style="${azUnlocked ? '' : 'opacity:.5'}"><b>${i + 1}.</b><span>${m.label}</span>
+            ${azUnlocked ? `<button class="btn sm" style="margin-left:auto" data-metro-az="${m.key}">Queue</button>` : ''}</div>`).join('')}
+        </div>
+      </div>
+      <div>
+        <div class="panel">
+          <h3>Query matrix (editable — no code changes)</h3>
+          <div id="qm"><span class="spin"></span></div>
+          <button class="btn sm" id="qm-add" style="margin-top:8px">+ Add query template</button>
+        </div>
+        <div class="panel">
+          <h3>Import data</h3>
+          <div class="form">
+            <div class="frow">
+              <div><label>Kind</label><select id="i-kind">
+                <option value="org_csv">Organizations CSV</option><option value="people_csv">People CSV</option>
+                <option value="contact_list">Existing contact list CSV</option><option value="linkedin_urls">LinkedIn URLs CSV</option></select></div>
+              <div><label>File</label><input id="i-file" type="file" accept=".csv,text/csv"></div>
+            </div>
+            <div><label>Usage note (your right to use this data)</label><input id="i-note" placeholder="e.g. Our own exported contact list"></div>
+            <div id="i-map"></div>
+            <div class="filters">
+              <button class="btn" id="i-preview" disabled>Preview + validate</button>
+              <button class="btn" id="i-dry" disabled>Dry run</button>
+              <button class="btn primary" id="i-run" disabled>Import</button>
+            </div>
+            <div id="i-out"></div>
+          </div>
+        </div>
+        <div class="panel"><h3>Import batches (reversible)</h3><div id="i-batches"><span class="spin"></span></div></div>
+      </div>
+    </div>`;
+
+  function fillMetros() {
+    const st = document.getElementById('r-state').value;
+    const metros = (st === 'CA' ? CA : AZ).metros;
+    document.getElementById('r-metro').innerHTML = '<option value="">All metros (ordered)</option>' + metros.map((m) => `<option value="${m.key}">${m.label}</option>`).join('');
+  }
+  fillMetros();
+  document.getElementById('r-state').addEventListener('change', fillMetros);
+  document.getElementById('r-start').addEventListener('click', () => queueRun(document.getElementById('r-state').value, document.getElementById('r-metro').value || null, document.getElementById('r-cat').value || null));
+  document.querySelectorAll('[data-metro]').forEach((b) => b.addEventListener('click', () => queueRun('CA', b.dataset.metro, null)));
+  document.querySelectorAll('[data-metro-az]').forEach((b) => b.addEventListener('click', () => queueRun('AZ', b.dataset.metroAz, null)));
+  async function queueRun(state, metro, category) {
+    try {
+      const r = await PR.api('/research/start', { method: 'POST', body: { state, metro, category } });
+      PR.toast(r.job.deduped ? 'Identical run already queued' : 'Discovery run queued (#' + r.job.id + ') — the worker will pick it up');
+    } catch (e) {
+      PR.toast(e.message, true);
+    }
+  }
+
+  async function loadMatrix() {
+    const rows = (await PR.api('/query-templates')).rows;
+    document.getElementById('qm').innerHTML = rows.map((t) => `
+      <div class="gatechk"><input type="checkbox" data-en="${t.id}" ${t.enabled ? 'checked' : ''}>
+        <b style="font-size:12px">${PR.esc(t.name)}</b>
+        <code style="flex:1;font-size:11.5px;color:var(--muted)">${PR.esc(t.template)}</code>
+        ${PR.badge(t.target)} <button class="btn sm" data-eq="${t.id}">Edit</button></div>`).join('');
+    document.querySelectorAll('[data-en]').forEach((cb) => cb.addEventListener('change', async () => {
+      const t = rows.find((x) => String(x.id) === cb.dataset.en);
+      await PR.api('/query-templates', { method: 'POST', body: { ...t, enabled: cb.checked } });
+      PR.toast((cb.checked ? 'Enabled' : 'Disabled') + ' ' + t.name);
+    }));
+    document.querySelectorAll('[data-eq]').forEach((b) => b.addEventListener('click', async () => {
+      const t = rows.find((x) => String(x.id) === b.dataset.eq);
+      const template = prompt('Query template ([metro] / [state] / [company name] slots):', t.template);
+      if (template == null) return;
+      await PR.guard(() => PR.api('/query-templates', { method: 'POST', body: { ...t, template } }), 'Saved');
+      loadMatrix();
+    }));
+  }
+  document.getElementById('qm-add').addEventListener('click', async () => {
+    const name = prompt('Template name:'); if (!name) return;
+    const template = prompt('Query ([metro] / [state] / [company name] slots):'); if (!template) return;
+    await PR.guard(() => PR.api('/query-templates', { method: 'POST', body: { name, template, target: 'organization' } }), 'Added');
+    loadMatrix();
+  });
+
+  // ── Import wizard ──
+  let csvText = null, headers = [];
+  const MAPPABLE = {
+    org_csv: ['display_name', 'legal_name', 'website_url', 'organization_type', 'state', 'metro', 'county', 'asset_classes'],
+    people_csv: ['full_name', 'exact_title', 'organization_name', 'organization_website', 'public_work_email', 'public_business_phone', 'linkedin_url', 'state', 'metro'],
+    contact_list: ['full_name', 'exact_title', 'organization_name', 'public_work_email', 'state', 'metro'],
+    linkedin_urls: ['linkedin_url', 'full_name', 'person_id'],
+  };
+  document.getElementById('i-file').addEventListener('change', async (e) => {
+    const f = e.target.files[0]; if (!f) return;
+    csvText = await f.text();
+    headers = (csvText.split(/\r?\n/)[0] || '').split(',').map((h) => h.replace(/^"|"$/g, '').trim());
+    const kind = document.getElementById('i-kind').value;
+    document.getElementById('i-map').innerHTML = '<label style="font-size:12px;color:var(--muted)">Column mapping (target ← CSV column)</label>' +
+      MAPPABLE[kind].map((t) => `<div class="frow" style="margin:2px 0"><span style="font-size:12.5px;padding-top:7px">${t}</span>
+        <select data-map="${t}"><option value="">— skip —</option>${headers.map((h) => `<option ${h.toLowerCase().replace(/\s/g, '_') === t ? 'selected' : ''}>${PR.esc(h)}</option>`).join('')}</select></div>`).join('');
+    ['i-preview', 'i-dry', 'i-run'].forEach((id) => document.getElementById(id).disabled = false);
+  });
+  function mapObj() {
+    const m = {};
+    document.querySelectorAll('[data-map]').forEach((s) => { if (s.value) m[s.dataset.map] = s.value; });
+    return m;
+  }
+  document.getElementById('i-preview').addEventListener('click', async () => {
+    const p = await PR.guard(() => PR.api('/import/preview', { method: 'POST', body: { csv: csvText, columnMap: mapObj(), kind: document.getElementById('i-kind').value } }));
+    document.getElementById('i-out').innerHTML = `<div class="evi"><b>${p.total_rows} rows.</b> ${p.issues.length ? '<span style="color:var(--warn)">' + p.issues.length + ' issue(s):</span> ' + p.issues.slice(0, 8).map(PR.esc).join('; ') : 'No validation issues.'}</div>`;
+  });
+  async function runImport(dry) {
+    const r = await PR.guard(() => PR.api('/import/run', { method: 'POST', body: {
+      kind: document.getElementById('i-kind').value, csv: csvText, columnMap: mapObj(), dry_run: dry,
+      source_note: document.getElementById('i-note').value || null,
+      filename: (document.getElementById('i-file').files[0] || {}).name,
+    } }), dry ? 'Dry run complete' : 'Import complete');
+    const rep = r.report;
+    document.getElementById('i-out').innerHTML = `<div class="evi"><b>${dry ? 'DRY RUN' : 'Batch #' + r.batch_id}</b> —
+      created ${rep.created.length} · updated ${rep.updated.length} · duplicates ${rep.duplicates.length} · skipped ${rep.skipped.length} · errors ${rep.errors.length}
+      ${rep.errors.length ? '<div class="x">' + rep.errors.slice(0, 5).map((e) => PR.esc(e.error)).join('; ') + '</div>' : ''}</div>`;
+    loadBatches();
+  }
+  document.getElementById('i-dry').addEventListener('click', () => runImport(true));
+  document.getElementById('i-run').addEventListener('click', () => runImport(false));
+
+  async function loadBatches() {
+    const rows = (await PR.api('/import/batches')).rows;
+    document.getElementById('i-batches').innerHTML = rows.length ? rows.map((b) => `
+      <div class="gatechk"><b>#${b.id}</b> ${PR.badge(b.kind)} <span>${PR.esc(b.filename || '')}</span>
+        <span style="color:var(--muted);font-size:12px">${b.row_count} rows · +${b.created_count} · dup ${b.duplicate_count}${b.dry_run ? ' · DRY' : ''}</span>
+        <span style="margin-left:auto;color:var(--muted);font-size:12px">${PR.dateShort(b.created_at)}</span>
+        ${b.reversed_at ? PR.badge('reversed', 'red') : (b.dry_run ? '' : `<button class="btn sm danger" data-rev="${b.id}">Reverse</button>`)}</div>`).join('') : '<div class="empty">No imports yet</div>';
+    document.querySelectorAll('[data-rev]').forEach((btn) => btn.addEventListener('click', async () => {
+      if (!confirm('Reverse batch #' + btn.dataset.rev + '? Its created records are archived (not deleted).')) return;
+      await PR.guard(() => PR.api('/import/' + btn.dataset.rev + '/reverse', { method: 'POST' }), 'Batch reversed');
+      loadBatches();
+    }));
+  }
+
+  loadMatrix(); loadBatches();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/review.html b/public/admin/pr-intelligence/review.html
new file mode 100644
index 00000000..00646f4d
--- /dev/null
+++ b/public/admin/pr-intelligence/review.html
@@ -0,0 +1,216 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Review Queue</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('review', 'Review Queue', 'Left: candidates. Center: profile + evidence + duplicates. Right: letter preview + notes. Keys: ↑/↓ navigate · A accept · R reject · D defer · S stale.');
+  const params = new URLSearchParams(location.search);
+  let kind = params.get('kind') === 'people' ? 'people' : 'organizations';
+  let items = [], idx = 0, current = null;
+
+  c.innerHTML = `
+    <div class="filters">
+      <select id="r-kind"><option value="organizations" ${kind === 'organizations' ? 'selected' : ''}>Organizations needing review</option>
+        <option value="people" ${kind === 'people' ? 'selected' : ''}>People needing verification</option></select>
+      <select id="r-status"></select>
+      <span id="r-count" style="color:var(--muted);font-size:12px"></span>
+    </div>
+    <div class="review3">
+      <div class="rlist" id="r-list"><div class="empty"><span class="spin"></span></div></div>
+      <div id="r-center"><div class="empty">Select a candidate</div></div>
+      <div id="r-right"><div class="panel"><h3>Letter preview</h3><div id="r-letter" class="empty">Select a person (or an org with people) to preview the grounded letter.</div></div>
+        <div class="panel"><h3>Internal research notes</h3><textarea id="r-notes" style="width:100%;min-height:90px;border:1px solid var(--line);border-radius:8px;padding:8px;font:13px inherit"></textarea>
+        <button class="btn sm" id="r-savenote" style="margin-top:6px">Save note</button></div>
+        <div class="panel" id="r-actions-right"></div>
+      </div>
+    </div>`;
+
+  const statusSel = document.getElementById('r-status');
+  function fillStatuses() {
+    const opts = kind === 'organizations'
+      ? [['needs_review', 'needs review'], ['discovered', 'discovered'], ['incomplete', 'incomplete'], ['verified', 'verified']]
+      : [['needs_verification', 'needs verification'], ['discovered', 'discovered'], ['stale', 'stale'], ['verified', 'verified']];
+    statusSel.innerHTML = opts.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
+  }
+  fillStatuses();
+
+  document.getElementById('r-kind').addEventListener('change', (e) => { kind = e.target.value; fillStatuses(); loadList(); });
+  statusSel.addEventListener('change', loadList);
+
+  async function loadList() {
+    const j = await PR.api(`/review/queue?kind=${kind}&status=${statusSel.value}&limit=100`);
+    items = j.rows; idx = 0;
+    document.getElementById('r-count').textContent = j.total + ' in queue';
+    const list = document.getElementById('r-list');
+    list.innerHTML = items.length ? items.map((r, i) => kind === 'organizations'
+      ? `<div class="it" data-i="${i}"><b>${PR.esc(r.display_name)}</b><span>${PR.esc(r.organization_type)} · conf ${r.confidence_score} · ${PR.esc((r.metros || []).join(','))}</span></div>`
+      : `<div class="it" data-i="${i}"><b>${PR.esc(r.full_name)}</b><span>${PR.esc(r.exact_title || '(no title)')} · ${PR.esc(r.organization_name || '')} · conf ${r.confidence_score}</span></div>`).join('')
+      : '<div class="empty">Queue is clear 🎉</div>';
+    list.querySelectorAll('.it').forEach((el) => el.addEventListener('click', () => select(Number(el.dataset.i))));
+    const focus = params.get('focus');
+    if (focus) { const fi = items.findIndex((x) => String(x.id) === focus); if (fi >= 0) idx = fi; }
+    if (items.length) select(idx);
+  }
+
+  async function select(i) {
+    idx = i;
+    document.querySelectorAll('#r-list .it').forEach((el) => el.classList.toggle('on', Number(el.dataset.i) === i));
+    const row = items[i]; if (!row) return;
+    current = kind === 'organizations' ? await PR.api('/organizations/' + row.id) : await PR.api('/people/' + row.id);
+    renderCenter();
+    renderRight();
+  }
+
+  function renderCenter() {
+    const o = current;
+    const isOrg = kind === 'organizations';
+    const dupBtn = `<button class="btn sm" id="c-dups">Find duplicate candidates</button>`;
+    document.getElementById('r-center').innerHTML = `
+      <div class="panel">
+        <h3>${PR.esc(isOrg ? o.display_name : o.full_name)} ${PR.statusBadge(o.lifecycle_status)}</h3>
+        ${isOrg ? `
+          <div class="kv">
+            <dt>Website</dt><dd>${o.website_url ? `<a href="${PR.esc(o.website_url)}" target="_blank" rel="noopener noreferrer">${PR.esc(o.website_url)}</a>` : '<span class="b amber">missing</span>'}</dd>
+            <dt>Category</dt><dd>${PR.badge(o.organization_type, 'blue')}</dd>
+            <dt>Geography</dt><dd>${(o.state_presence || []).join(',')} · ${(o.metros || []).join(', ') || '<span class="b amber">no metro</span>'}</dd>
+            <dt>Press</dt><dd>${o.press_page_url ? `<a href="${PR.esc(o.press_page_url)}" target="_blank" rel="noopener noreferrer">press page</a>` : '—'} · ${PR.esc(o.general_press_email || 'no press email')}</dd>
+            <dt>People</dt><dd>${o.people.length}</dd>
+            <dt>Scores</dt><dd>conf ${o.confidence_score} · priority ${o.priority_score}</dd>
+            <dt>Created</dt><dd>🕓 ${PR.date(o.created_at)}</dd>
+          </div>` : `
+          <div class="kv">
+            <dt>Title</dt><dd>${PR.esc(o.exact_title || '—')} ${PR.badge(o.normalized_role || '', 'blue')}</dd>
+            <dt>Organization</dt><dd>${PR.esc(o.organization_name || '—')}</dd>
+            <dt>Email</dt><dd>${PR.esc(o.public_work_email || '—')} ${PR.verifBadge(o.email_verification_status)}</dd>
+            <dt>LinkedIn</dt><dd>${o.linkedin_url ? `<a href="${PR.esc(o.linkedin_url)}" target="_blank" rel="noopener noreferrer">profile</a>` : '—'} ${PR.verifBadge(o.linkedin_status)}
+              ${o.linkedin_indexed_title ? `<div style="font-size:11px;color:var(--muted)">indexed: “${PR.esc(o.linkedin_indexed_title)}” (needs corroboration)</div>` : ''}</dd>
+            <dt>Scores</dt><dd>conf ${o.confidence_score} · priority ${o.contact_priority_score}</dd>
+            <dt>Created</dt><dd>🕓 ${PR.date(o.created_at)}</dd>
+          </div>`}
+        <div class="filters">
+          <button class="btn sm primary" id="c-accept">✓ Accept (A)</button>
+          <button class="btn sm" id="c-edit">✎ Edit</button>
+          <button class="btn sm" id="c-defer">Defer (D)</button>
+          <button class="btn sm" id="c-stale">Mark stale (S)</button>
+          <button class="btn sm danger" id="c-reject">✗ Reject (R)</button>
+          ${dupBtn}
+        </div>
+        <div id="c-dups-out"></div>
+      </div>
+      <div class="panel"><h3>Evidence (${(o.evidence || []).length})</h3>${PR.evidenceHtml(o.evidence, { addToLetter: true })}</div>`;
+    document.getElementById('c-accept').addEventListener('click', () => act('accept'));
+    document.getElementById('c-reject').addEventListener('click', () => act('reject'));
+    document.getElementById('c-defer').addEventListener('click', () => act('defer'));
+    document.getElementById('c-stale').addEventListener('click', () => act('stale'));
+    document.getElementById('c-edit').addEventListener('click', editCurrent);
+    document.getElementById('c-dups').addEventListener('click', showDups);
+    document.querySelectorAll('#r-center [data-addletter]').forEach((a) => a.addEventListener('click', (e) => {
+      e.preventDefault();
+      localStorage.setItem('pr-letter-clip', JSON.stringify({ org_id: isOrg ? o.id : o.organization_id, source_id: Number(a.dataset.addletter), excerpt: a.dataset.excerpt }));
+      PR.toast('Added to letter clipboard');
+    }));
+  }
+
+  async function renderRight() {
+    const o = current; const isOrg = kind === 'organizations';
+    const personId = isOrg ? (o.people[0] || {}).id : o.id;
+    const letterBox = document.getElementById('r-letter');
+    if (!personId) { letterBox.innerHTML = '<div class="empty">No person on this org yet</div>'; }
+    else {
+      try {
+        const prev = await PR.api('/letters/preview', { method: 'POST', body: { person_id: personId } });
+        letterBox.innerHTML = `<div style="font-size:12px;color:var(--muted);margin-bottom:6px">Subject: <b style="color:var(--ink)">${PR.esc(prev.subject)}</b></div>
+          <div class="letter-preview" style="max-height:300px;overflow:auto;font-size:12.5px">${prev.text.split('\n\n').map((p) => PR.esc(p)).join('\n\n').replace(/\{\{([a-z0-9_]+)\}\}/gi, '<span class="missing">{{$1}}</span>')}</div>
+          ${prev.missing.length ? '<div style="font-size:11px;color:var(--warn);margin-top:6px">Unfilled: ' + prev.missing.join(', ') + '</div>' : ''}
+          <div class="filters" style="margin-top:8px">
+            <button class="btn sm" id="rr-draft">Save as draft</button>
+            <button class="btn sm" id="rr-provider" title="Draft must be generated + approved first" disabled>Create provider draft</button>
+          </div>`;
+        letterBox.querySelector('#rr-draft').addEventListener('click', async () => {
+          const r = await PR.guard(() => PR.api('/outreach/draft', { method: 'POST', body: { person_id: personId } }), 'Draft saved — open the Letter Builder or Campaigns to approve');
+          letterBox.querySelector('#rr-provider').disabled = false;
+          letterBox.querySelector('#rr-provider').dataset.mid = r.message.id;
+        });
+        letterBox.querySelector('#rr-provider').addEventListener('click', async (e) => {
+          const mid = e.target.dataset.mid; if (!mid) return;
+          await PR.guard(() => PR.api('/outreach/' + mid + '/approve', { method: 'POST' }));
+          await PR.guard(() => PR.api('/outreach/' + mid + '/provider-draft', { method: 'POST' }), 'Provider draft created (nothing sent)');
+        });
+      } catch (e) { letterBox.innerHTML = '<div class="empty">' + PR.esc(e.message) + '</div>'; }
+    }
+    document.getElementById('r-notes').value = o.notes || '';
+    document.getElementById('r-savenote').onclick = async () => {
+      const api = isOrg ? '/organizations/' : '/people/';
+      await PR.guard(() => PR.api(api + o.id, { method: 'PUT', body: { notes: document.getElementById('r-notes').value } }), 'Note saved');
+    };
+    document.getElementById('r-actions-right').innerHTML = `<h3>Reply history</h3>` +
+      ((o.outreach || []).map((m) => `<div class="gatechk">${PR.statusBadge(m.status)} <span>${PR.esc(m.subject || '')}</span></div>`).join('') || '<div class="empty">None</div>');
+  }
+
+  async function act(action) {
+    const o = current; if (!o) return;
+    const kindSingular = kind === 'organizations' ? 'organization' : 'person';
+    await PR.guard(() => PR.api(`/review/${kindSingular}/${o.id}`, { method: 'POST', body: { action } }), action + ' ✓');
+    items.splice(idx, 1);
+    loadListSoft();
+  }
+  function loadListSoft() {
+    const list = document.getElementById('r-list');
+    document.getElementById('r-count').textContent = items.length + ' in queue';
+    list.innerHTML = items.length ? items.map((r, i) => kind === 'organizations'
+      ? `<div class="it" data-i="${i}"><b>${PR.esc(r.display_name)}</b><span>${PR.esc(r.organization_type)} · conf ${r.confidence_score}</span></div>`
+      : `<div class="it" data-i="${i}"><b>${PR.esc(r.full_name)}</b><span>${PR.esc(r.exact_title || '')} · conf ${r.confidence_score}</span></div>`).join('')
+      : '<div class="empty">Queue is clear 🎉</div>';
+    list.querySelectorAll('.it').forEach((el) => el.addEventListener('click', () => select(Number(el.dataset.i))));
+    if (items.length) select(Math.min(idx, items.length - 1));
+    else { document.getElementById('r-center').innerHTML = '<div class="empty">Queue is clear 🎉</div>'; document.getElementById('r-letter').innerHTML = ''; }
+  }
+
+  function editCurrent() {
+    const o = current; const isOrg = kind === 'organizations';
+    const patchStr = prompt('Edit JSON patch (allowed fields per record type):', isOrg
+      ? JSON.stringify({ display_name: o.display_name, website_url: o.website_url, organization_type: o.organization_type, metros: o.metros })
+      : JSON.stringify({ exact_title: o.exact_title, public_work_email: o.public_work_email, email_verification_status: o.email_verification_status }));
+    if (!patchStr) return;
+    let patch; try { patch = JSON.parse(patchStr); } catch { return PR.toast('Invalid JSON', true); }
+    PR.guard(() => PR.api(`/review/${isOrg ? 'organization' : 'person'}/${o.id}`, { method: 'POST', body: { action: 'edit', patch } }), 'Edited').then(() => select(idx));
+  }
+
+  async function showDups() {
+    const o = current;
+    if (kind !== 'organizations') return PR.toast('Duplicate scan UI is org-first; person dups auto-flag on create', true);
+    const j = await PR.guard(() => PR.api('/organizations/' + o.id + '/matches'));
+    const out = document.getElementById('c-dups-out');
+    out.innerHTML = j.matches.length ? j.matches.map((m) => `
+      <div class="evi"><b>${PR.esc(m.org.display_name)}</b> ${PR.badge(m.verdict, 'amber')} score ${m.score}
+        <div class="x">${m.reasons.map(PR.esc).join(' · ')}</div>
+        <button class="btn sm" data-merge="${m.org.id}">Merge into current</button></div>`).join('')
+      : '<div class="empty">No duplicate candidates</div>';
+    out.querySelectorAll('[data-merge]').forEach((b) => b.addEventListener('click', async () => {
+      if (!confirm('Merge #' + b.dataset.merge + ' into current?')) return;
+      await PR.guard(() => PR.api('/organizations/' + o.id + '/merge', { method: 'POST', body: { duplicate_id: Number(b.dataset.merge) } }), 'Merged');
+      select(idx);
+    }));
+  }
+
+  document.addEventListener('keydown', (e) => {
+    if (/input|textarea|select/i.test(document.activeElement.tagName)) return;
+    if (e.key === 'ArrowDown') { e.preventDefault(); if (idx < items.length - 1) select(idx + 1); }
+    else if (e.key === 'ArrowUp') { e.preventDefault(); if (idx > 0) select(idx - 1); }
+    else if (e.key === 'a' || e.key === 'A') act('accept');
+    else if (e.key === 'r' || e.key === 'R') act('reject');
+    else if (e.key === 'd' || e.key === 'D') act('defer');
+    else if (e.key === 's' || e.key === 'S') act('stale');
+  });
+
+  loadList();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/runs.html b/public/admin/pr-intelligence/runs.html
new file mode 100644
index 00000000..558c1c4a
--- /dev/null
+++ b/public/admin/pr-intelligence/runs.html
@@ -0,0 +1,80 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Runs & Jobs</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('runs', 'Runs & Jobs', 'Resumable research runs (with checkpoints) and the database-backed job queue. The worker process executes: npm run pr:worker.');
+  const meta = await PR.api('/meta');
+  c.innerHTML = `
+    <div class="filters">
+      <select id="j-type">${meta.job_types.map((t) => `<option>${t}</option>`).join('')}</select>
+      <input id="j-payload" placeholder='payload JSON e.g. {"state":"CA"}' style="min-width:260px">
+      <button class="btn primary" id="j-add">Queue job</button>
+      <button class="btn" id="refresh">↻ Refresh</button>
+      <label style="font-size:12px;color:var(--muted)"><input type="checkbox" id="auto" checked> auto-refresh 5s</label>
+    </div>
+    <div class="cols2">
+      <div class="panel"><h3>Research runs</h3><div id="runs"><span class="spin"></span></div></div>
+      <div class="panel"><h3>Job queue</h3><div id="jobs"><span class="spin"></span></div></div>
+    </div>`;
+
+  async function load() {
+    const rj = await PR.api('/runs');
+    document.getElementById('runs').innerHTML = rj.rows.length ? rj.rows.map((r) => `
+      <div class="evi" style="border-color:${r.status === 'failed' ? 'var(--bad)' : r.status === 'running' ? 'var(--accent)' : 'var(--line)'}">
+        <b>#${r.id}</b> ${PR.badge(r.state, 'blue')} ${r.metro ? PR.badge(r.metro) : ''} ${r.category ? PR.badge(r.category) : ''}
+        ${PR.statusBadge(r.status)} <span style="color:var(--muted);font-size:11.5px">adapter ${PR.esc(r.adapter)}</span>
+        <div class="x">pages ${r.pages_checked} · candidates ${r.candidates_found} · created ${r.records_created} · updated ${r.records_updated} · dups ${r.duplicates_detected} · retries ${r.retry_count}
+          ${(r.errors || []).length ? ' · <span style="color:var(--bad)">' + r.errors.length + ' error(s)</span>' : ''}</div>
+        ${(r.errors || []).length ? `<div class="x" style="color:var(--bad)">${PR.esc(String((r.errors[r.errors.length - 1] || {}).error || '').slice(0, 160))}</div>` : ''}
+        <div style="font-size:11px;color:var(--muted)">checkpoint: <code>${PR.esc(JSON.stringify(r.checkpoint).slice(0, 90))}</code> · 🕓 ${PR.date(r.created_at)}</div>
+        <div class="filters" style="margin-top:6px">
+          ${['queued', 'running'].includes(r.status) ? `<button class="btn sm" data-pause="${r.id}">⏸ Pause</button>` : ''}
+          ${r.status === 'paused' ? `<button class="btn sm primary" data-resume="${r.id}">▶ Resume</button>` : ''}
+        </div></div>`).join('') : '<div class="empty">No runs yet — start one from Research</div>';
+    document.querySelectorAll('[data-pause]').forEach((b) => b.addEventListener('click', () =>
+      PR.guard(() => PR.api('/runs/' + b.dataset.pause + '/pause', { method: 'POST' }), 'Paused (jobs paused too)').then(load)));
+    document.querySelectorAll('[data-resume]').forEach((b) => b.addEventListener('click', () =>
+      PR.guard(() => PR.api('/runs/' + b.dataset.resume + '/resume', { method: 'POST' }), 'Resumed — resumes from its checkpoint').then(load)));
+
+    const jj = await PR.api('/jobs?limit=60');
+    document.getElementById('jobs').innerHTML = jj.rows.length ? `<table class="grid"><thead><tr>
+      <th>#</th><th>Type</th><th>Status</th><th>Attempts</th><th>Payload</th><th>Last error</th><th></th></tr></thead><tbody>` +
+      jj.rows.map((j) => `<tr>
+        <td>${j.id}</td><td style="font-size:12px">${PR.esc(j.job_type)}</td>
+        <td>${PR.statusBadge(j.status)}</td><td>${j.attempts}/${j.max_attempts}</td>
+        <td><code style="font-size:11px">${PR.esc(JSON.stringify(j.payload).slice(0, 60))}</code></td>
+        <td style="font-size:11px;color:var(--bad)">${PR.esc((j.last_error || '').slice(0, 60))}</td>
+        <td>${['queued', 'running'].includes(j.status) ? `<button class="btn sm" data-jp="${j.id}">⏸</button>` : ''}
+            ${['paused', 'failed'].includes(j.status) ? `<button class="btn sm" data-jr="${j.id}">▶ retry</button>` : ''}
+            ${j.status !== 'completed' && j.status !== 'cancelled' ? `<button class="btn sm danger" data-jc="${j.id}">✕</button>` : ''}</td></tr>`).join('') + '</tbody></table>'
+      : '<div class="empty">Queue empty</div>';
+    document.querySelectorAll('[data-jp]').forEach((b) => b.addEventListener('click', () => setJ(b.dataset.jp, 'paused')));
+    document.querySelectorAll('[data-jr]').forEach((b) => b.addEventListener('click', () => setJ(b.dataset.jr, 'queued')));
+    document.querySelectorAll('[data-jc]').forEach((b) => b.addEventListener('click', () => setJ(b.dataset.jc, 'cancelled')));
+  }
+  async function setJ(id, status) { await PR.guard(() => PR.api('/jobs/' + id + '/status', { method: 'POST', body: { status } }), status); load(); }
+
+  document.getElementById('j-add').addEventListener('click', async () => {
+    let payload = {};
+    const t = document.getElementById('j-payload').value.trim();
+    if (t) { try { payload = JSON.parse(t); } catch { return PR.toast('Payload must be valid JSON', true); } }
+    try {
+      const r = await PR.api('/jobs', { method: 'POST', body: { job_type: document.getElementById('j-type').value, payload } });
+      PR.toast(r.job.deduped ? 'Identical job already queued' : 'Job queued');
+      load();
+    } catch (e) { PR.toast(e.message, true); }
+  });
+  document.getElementById('refresh').addEventListener('click', load);
+  setInterval(() => { if (document.getElementById('auto').checked) load(); }, 5000);
+  load();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/settings.html b/public/admin/pr-intelligence/settings.html
new file mode 100644
index 00000000..6fb682bc
--- /dev/null
+++ b/public/admin/pr-intelligence/settings.html
@@ -0,0 +1,150 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Settings</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('settings', 'Settings', 'Sender identity (CAN-SPAM), submission method, refresh cadence, the California quality gate, the Arizona unlock, email provider, suppression, and audit log.');
+  c.innerHTML = `
+    <div class="cols2">
+      <div>
+        <div class="panel">
+          <h3>Sender & letter settings</h3>
+          <div class="form" id="sform"><span class="spin"></span></div>
+        </div>
+        <div class="panel">
+          <h3>Email provider</h3>
+          <div id="prov"></div>
+        </div>
+        <div class="panel">
+          <h3>Suppression list</h3>
+          <div class="filters">
+            <input id="sup-email" placeholder="email or leave blank">
+            <input id="sup-reason" placeholder="reason" value="manual">
+            <button class="btn sm" id="sup-add">Add suppression</button>
+            <a class="btn sm" href="/api/pr/export/suppression">⬇ Export</a>
+          </div>
+          <div id="sup-list"><span class="spin"></span></div>
+        </div>
+      </div>
+      <div>
+        <div class="panel">
+          <h3>California quality gate</h3>
+          <button class="btn primary" id="gate-check">Run gate check now</button>
+          <div id="gate-out" style="margin-top:10px"></div>
+        </div>
+        <div class="panel">
+          <h3>Arizona phase</h3>
+          <div id="az-state"></div>
+          <div class="filters" style="margin-top:8px">
+            <button class="btn" id="az-unlock">Unlock Arizona (requires passing gate)</button>
+            <button class="btn danger" id="az-override">Admin override unlock (audit-logged)</button>
+          </div>
+        </div>
+        <div class="panel">
+          <h3>Audit log (latest 50)</h3>
+          <div id="audit"><span class="spin"></span></div>
+        </div>
+      </div>
+    </div>`;
+
+  const EDITABLE = ['sender_name', 'sender_title', 'sender_contact_information', 'sender_postal_address',
+    'approved_submission_method', 'unsubscribe_text', 'letter_main_purpose', 'refresh_days_high', 'refresh_days_normal'];
+  async function loadSettings() {
+    const all = await PR.api('/settings');
+    document.getElementById('sform').innerHTML = EDITABLE.map((k) => {
+      const v = all[k] ? (typeof all[k].value === 'string' ? all[k].value : JSON.stringify(all[k].value)) : '';
+      const req = ['sender_name', 'sender_title', 'sender_contact_information', 'approved_submission_method'].includes(k);
+      return `<div><label>${k}${req ? ' * <span style="color:var(--warn)">(required before any letter can send)</span>' : ''}</label>
+        ${k === 'letter_main_purpose' || k === 'unsubscribe_text' ? `<textarea data-k="${k}">${PR.esc(v)}</textarea>` : `<input data-k="${k}" value="${PR.esc(v)}">`}</div>`;
+    }).join('') + '<button class="btn primary" id="s-save">Save settings</button>';
+    document.getElementById('s-save').addEventListener('click', async () => {
+      for (const el of document.querySelectorAll('[data-k]')) {
+        const k = el.dataset.k;
+        const v = /refresh_days/.test(k) ? Number(el.value) || 0 : el.value;
+        await PR.api('/settings/' + k, { method: 'PUT', body: { value: v } });
+      }
+      PR.toast('Settings saved');
+    });
+    const meta = await PR.api('/meta');
+    const p = meta.email_provider;
+    document.getElementById('prov').innerHTML = `
+      <div class="gatechk"><span class="s">${p.gmail_configured ? '🟢' : '⚪️'}</span><b>Gmail</b>
+        <span style="color:var(--muted);font-size:12px">${p.gmail_configured ? 'connected (OAuth refresh token)' : 'missing env: ' + p.gmail_missing_env.join(', ')}</span></div>
+      <div class="gatechk"><span class="s">${p.active === 'mock' ? '🟡' : '⚪️'}</span><b>Mock</b>
+        <span style="color:var(--muted);font-size:12px">active when Gmail is unconfigured — drafts stored locally, nothing sends</span></div>
+      <p style="font-size:12px;color:var(--muted)">Setup: docs/CRE_PR_EMAIL_SETUP.md · vars go in the environment, never in source control.</p>`;
+    renderAz(all);
+  }
+
+  function renderAz(all) {
+    const unlocked = all.arizona_unlocked && all.arizona_unlocked.value === true;
+    document.getElementById('az-state').innerHTML = unlocked
+      ? PR.badge('UNLOCKED — Arizona research jobs may be queued', 'green')
+      : PR.badge('🔒 LOCKED — AZ discovery jobs refuse to enqueue', 'amber') +
+        `<p style="font-size:12.5px;color:var(--muted);margin:8px 0 0">${PR.esc((all.arizona_unlock_note || {}).value || '')}</p>`;
+  }
+
+  document.getElementById('gate-check').addEventListener('click', async () => {
+    const g = await PR.guard(() => PR.api('/gate/check', { method: 'POST' }), 'Gate evaluated');
+    const item = (ok, label, extra) => `<div class="gatechk"><span class="s">${ok ? '✅' : '❌'}</span><span>${label}${extra ? ' — ' + extra : ''}</span></div>`;
+    const ck = g.checks;
+    document.getElementById('gate-out').innerHTML =
+      (g.passed ? PR.badge('GATE PASSED', 'green') : PR.badge('GATE NOT PASSED', 'red')) +
+      item(ck.pipeline_resumable, 'Pipeline resumable after interruption') +
+      item(ck.dedupe_working, 'Org/people deduplication exercised') +
+      item(ck.org_completeness.pass, '≥80% reviewed CA orgs complete', ck.org_completeness.pct + '% of ' + ck.org_completeness.reviewed + ' reviewed') +
+      item(ck.contact_coverage.pass, '≥70% high-priority CA orgs have a relevant contact', ck.contact_coverage.pct + '% of ' + ck.contact_coverage.high_priority) +
+      item(ck.workflows_exercised, 'Review / letter / outreach / opt-out flows exercised') +
+      item(ck.no_mislabeled_inferred, 'No inferred contact marked sendable') +
+      item(ck.dashboard_gaps_visible, 'Coverage gaps visible on the dashboard');
+  });
+  document.getElementById('az-unlock').addEventListener('click', async () => {
+    const r = await PR.guard(() => PR.api('/gate/unlock-arizona', { method: 'POST', body: {} }));
+    if (r.ok) { PR.toast('Arizona unlocked 🎉'); loadSettings(); }
+    else PR.toast(r.error, true);
+  });
+  document.getElementById('az-override').addEventListener('click', async () => {
+    if (!confirm('Override the California gate and unlock Arizona anyway? This is audit-logged.')) return;
+    await PR.guard(() => PR.api('/gate/unlock-arizona', { method: 'POST', body: { override: true } }), 'Arizona unlocked by override (audit-logged)');
+    loadSettings();
+  });
+
+  document.getElementById('sup-add').addEventListener('click', async () => {
+    const email = document.getElementById('sup-email').value.trim() || null;
+    if (!email) return PR.toast('Enter an email to suppress', true);
+    await PR.guard(() => PR.api('/suppression', { method: 'POST', body: { email, reason: document.getElementById('sup-reason').value || 'manual' } }), 'Suppressed');
+    loadSup();
+  });
+  async function loadSup() {
+    const j = await PR.api('/suppression?limit=50');
+    document.getElementById('sup-list').innerHTML = j.rows.length ? j.rows.map((s) => `
+      <div class="gatechk"><b>${PR.esc(s.email || s.domain || ('person #' + s.person_id))}</b>
+        ${PR.badge(s.reason, s.reason === 'opt_out' ? 'red' : 'amber')} ${s.permanent ? PR.badge('permanent', 'red') : PR.badge('temporary')}
+        <span style="color:var(--muted);font-size:11.5px">${PR.esc(s.full_name || '')} · 🕓 ${PR.date(s.created_at)}</span>
+        ${!(s.permanent && s.reason === 'opt_out') ? `<button class="btn sm" style="margin-left:auto" data-un="${s.id}">Remove</button>` : ''}</div>`).join('')
+      : '<div class="empty">Suppression list is empty</div>';
+    document.querySelectorAll('[data-un]').forEach((b) => b.addEventListener('click', async () => {
+      await PR.guard(() => PR.api('/suppression/' + b.dataset.un, { method: 'DELETE' }), 'Removed');
+      loadSup();
+    }));
+  }
+
+  async function loadAudit() {
+    const j = await PR.api('/audit');
+    document.getElementById('audit').innerHTML = j.rows.slice(0, 50).map((a) => `
+      <div class="gatechk"><span style="color:var(--muted);font-size:11px;white-space:nowrap">${PR.date(a.at)}</span>
+        <b style="font-size:12px">${PR.esc(a.actor)}</b><span style="font-size:12px">${PR.esc(a.action)}</span>
+        <span style="color:var(--muted);font-size:11.5px">${PR.esc(a.entity_type || '')} ${a.entity_id || ''}</span></div>`).join('') || '<div class="empty">No audit entries</div>';
+  }
+
+  loadSettings(); loadSup(); loadAudit();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/sources.html b/public/admin/pr-intelligence/sources.html
new file mode 100644
index 00000000..597ed81d
--- /dev/null
+++ b/public/admin/pr-intelligence/sources.html
@@ -0,0 +1,45 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Sources</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('sources', 'Sources & Evidence', 'Every important fact points at a source row: URL, limited excerpt, retrieval date, usage note. Full page copies are never stored.');
+  let offset = 0; const limit = 50;
+  const { bar, state } = PR.filters([
+    { key: 'q', placeholder: 'Search source name / URL…' },
+    { key: 'type', type: 'select', options: [['', 'Type: all'], ['org_website', 'org website'], ['gov_registry', 'gov registry'], ['sec_edgar', 'SEC EDGAR'], ['press_release', 'press release'], ['news_article', 'news article'], ['association', 'association'], ['search_api', 'search API'], ['user_import', 'user import'], ['manual', 'manual']] },
+  ], () => { offset = 0; load(); });
+  c.appendChild(bar);
+  const actions = document.createElement('div'); actions.className = 'filters';
+  actions.innerHTML = `<a class="btn" href="/api/pr/export/evidence">⬇ Export all field evidence CSV</a>`;
+  c.appendChild(actions);
+
+  const tbl = PR.table([
+    { key: 'name', label: 'Source', render: (r) => `<b>${PR.esc(r.source_name)}</b><br>${r.url ? `<a href="${PR.esc(r.url)}" target="_blank" rel="noopener noreferrer" style="font-size:12px">${PR.esc(r.url.slice(0, 80))}</a>` : ''}` },
+    { key: 'type', label: 'Type', render: (r) => PR.badge(r.source_type, r.is_primary_source ? 'green' : 'gray') + (r.is_primary_source ? ' ' + PR.badge('primary', 'green') : '') },
+    { key: 'excerpt', label: 'Excerpt', render: (r) => `<span style="font-size:12px;color:var(--muted)">${PR.esc((r.short_excerpt || '').slice(0, 120))}</span>` },
+    { key: 'evidence', label: 'Evidence rows', render: (r) => String(r.evidence_count) },
+    { key: 'terms', label: 'Terms', render: (r) => r.license_or_usage_note ? `<span title="${PR.esc(r.license_or_usage_note)}" style="cursor:help">ⓘ</span>` : '—' },
+    { key: 'retrieved', label: 'Retrieved', render: (r) => `<span title="${PR.esc(r.retrieved_at)}">🕓 ${PR.date(r.retrieved_at)}</span>` },
+    { key: 'status', label: 'Status', render: (r) => PR.badge(r.status, r.status === 'active' ? 'green' : 'amber') },
+  ]);
+  c.appendChild(tbl.el);
+  const pagerBox = document.createElement('div'); c.appendChild(pagerBox);
+
+  async function load() {
+    const q = new URLSearchParams({ ...Object.fromEntries(Object.entries(state).filter(([, v]) => v)), limit, offset });
+    const j = await PR.api('/sources?' + q);
+    tbl.render(j.rows);
+    pagerBox.innerHTML = ''; pagerBox.appendChild(PR.pager(j.total, limit, offset, (o) => { offset = o; load(); }));
+  }
+  load();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/tasks.html b/public/admin/pr-intelligence/tasks.html
new file mode 100644
index 00000000..514e6dcc
--- /dev/null
+++ b/public/admin/pr-intelligence/tasks.html
@@ -0,0 +1,46 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Tasks</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('tasks', 'Tasks', 'Follow-ups, verification chores, and referral actions. Each card shows its created date + time.');
+  c.innerHTML = `
+    <div class="filters">
+      <select id="t-status"><option value="open">Open</option><option value="done">Done</option><option value="dismissed">Dismissed</option><option value="all">All</option></select>
+      <button class="btn" id="t-add">+ Add task</button>
+    </div>
+    <div id="t-list"><span class="spin"></span></div>`;
+
+  async function load() {
+    const rows = (await PR.api('/tasks?status=' + document.getElementById('t-status').value)).rows;
+    document.getElementById('t-list').innerHTML = rows.length ? rows.map((t) => `
+      <div class="panel" style="padding:12px 16px;display:flex;gap:12px;align-items:center">
+        <div style="flex:1"><b>${PR.esc(t.title)}</b>
+          ${t.entity_name ? ` · <a href="/admin/pr-intelligence/${t.entity_type === 'person' ? 'people' : 'organizations'}?id=${t.entity_id}">${PR.esc(t.entity_name)}</a>` : ''}
+          ${t.detail ? `<div style="color:var(--muted);font-size:12.5px">${PR.esc(t.detail)}</div>` : ''}
+          <div style="color:var(--muted);font-size:11.5px" title="${PR.esc(t.created_at)}">🕓 created ${PR.date(t.created_at)}${t.due_at ? ' · due ' + PR.date(t.due_at) : ''}${t.created_by ? ' · by ' + PR.esc(t.created_by) : ''}</div></div>
+        ${PR.statusBadge(t.status === 'open' ? (t.due_at && new Date(t.due_at) < new Date() ? 'follow_up_due' : 'queued') : t.status === 'done' ? 'completed' : 'cancelled')}
+        ${t.status === 'open' ? `<button class="btn sm primary" data-done="${t.id}">✓ Done</button><button class="btn sm" data-dismiss="${t.id}">Dismiss</button>` : ''}
+      </div>`).join('') : '<div class="empty">No tasks</div>';
+    document.querySelectorAll('[data-done]').forEach((b) => b.addEventListener('click', () =>
+      PR.guard(() => PR.api('/tasks/' + b.dataset.done, { method: 'PUT', body: { status: 'done' } }), 'Done').then(load)));
+    document.querySelectorAll('[data-dismiss]').forEach((b) => b.addEventListener('click', () =>
+      PR.guard(() => PR.api('/tasks/' + b.dataset.dismiss, { method: 'PUT', body: { status: 'dismissed' } }), 'Dismissed').then(load)));
+  }
+  document.getElementById('t-status').addEventListener('change', load);
+  document.getElementById('t-add').addEventListener('click', async () => {
+    const title = prompt('Task:'); if (!title) return;
+    await PR.guard(() => PR.api('/tasks', { method: 'POST', body: { title } }), 'Added');
+    load();
+  });
+  load();
+})();
+</script>
+</body>
+</html>
diff --git a/public/backend.html b/public/backend.html
index 7f561123..4ed0abad 100644
--- a/public/backend.html
+++ b/public/backend.html
@@ -37,6 +37,7 @@
     <a class="card" href="/desk"><div class="ico">🗂️</div><b>Broker &amp; Owner Desk</b><span>382k brokers · 34k firms · sublease registry</span></a>
     <a class="card" href="/audience"><div class="ico">🎯</div><b>Audience &amp; CRM</b><span>Subscribers + sublease brokers, auto-segmented</span></a>
     <a class="card" href="/desk-admin"><div class="ico">✍️</div><b>Desk Admin</b><span>Add / withdraw sublease inventory</span></a>
+    <a class="card" href="/admin/pr-intelligence"><div class="ico">📡</div><b>CRE PR Intelligence</b><span>CA/AZ press-contact research · letters · replies</span></a>
   </div>
 
   <div class="sect">Build &amp; Dashboards</div>

← 7a3217d0 auto-save: 2026-07-30T11:17:19 (4 files) — package.json serv  ·  back to Rentv 2026  ·  pr-intelligence: CA seed (178 curated + FDIC registry), unit d0589dad →