[object Object]

← back to Norma

instagram-agent: add /live daily-posts board — real DW feed (Graph /media, paginated+cached, grouped by day) with delete-any (reuses proven openclaw delete path), sort+density controls, hourly refresh cron

7ff015219228a6bee4f2b7e54c5a197191dc2411 · 2026-08-18 11:47:05 -0700 · Steve Abrams

Files touched

Diff

commit 7ff015219228a6bee4f2b7e54c5a197191dc2411
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 11:47:05 2026 -0700

    instagram-agent: add /live daily-posts board — real DW feed (Graph /media, paginated+cached, grouped by day) with delete-any (reuses proven openclaw delete path), sort+density controls, hourly refresh cron
---
 agents/instagram-agent/live-media.js           | 190 +++++++++++++++++++++++++
 agents/instagram-agent/public/live-viewer.html | 182 +++++++++++++++++++++++
 agents/instagram-agent/server.js               |  19 +++
 3 files changed, 391 insertions(+)

diff --git a/agents/instagram-agent/live-media.js b/agents/instagram-agent/live-media.js
new file mode 100644
index 0000000..fc59de2
--- /dev/null
+++ b/agents/instagram-agent/live-media.js
@@ -0,0 +1,190 @@
+/**
+ * live-media.js — "all the daily Instagram posts" board for norma-instagram (:9810).
+ *
+ * The /posts board (posts-api.js) only shows posts NORMA auto-published (the ledger).
+ * This board shows the DW account's REAL feed straight from the Instagram Graph API
+ * (@designerwallcoverings, ~5,500 posts), grouped by day, with delete-any.
+ *
+ * Doctrine (matches server.js): a PAGE VIEW never calls the Graph API. A cron + an
+ * explicit "Refresh" button paginate /media into data/live-media.json; the viewer and
+ * /api/live/posts only READ that cache.
+ *
+ * Delete reuses the proven /api/posts/delete route in posts-api.js — same two honest
+ * modes (tombstone = hide from board only; live = openclaw really deletes on IG, gated
+ * by IG_LIVE_DELETE). Live media items carry a real permalink, so the same path works.
+ *
+ * Routes registered on the caller's express app (before start()'s 404 catch-all):
+ *   GET  /live                     → the viewer HTML (public/live-viewer.html)
+ *   GET  /api/live/posts           → { account, cached_at, total, days:[{date,label,posts:[…]}] }
+ *   POST /api/live/refresh         → paginate newest-first into the cache ({full:true} = whole history)
+ *   (delete is POST /api/posts/delete, already registered by posts-api.js)
+ */
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const DIR = __dirname;
+const DATA = path.join(DIR, 'data');
+const CACHE = path.join(DATA, 'live-media.json');
+const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
+const VIEWER = path.join(DIR, 'public', 'live-viewer.html');
+
+const HOST = (process.env.IG_GRAPH_HOST || 'https://graph.facebook.com').replace(/^https?:\/\//, '');
+const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
+const FIELDS = 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count';
+const hasCreds = () => !!(process.env.IG_USER_ID && process.env.IG_ACCESS_TOKEN);
+
+// ── tiny GET-JSON (no deps) ───────────────────────────────────────────────────
+function getJSON(url) {
+  return new Promise((resolve, reject) => {
+    https.get(url, (r) => {
+      let d = '';
+      r.on('data', (c) => (d += c));
+      r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
+    }).on('error', reject);
+  });
+}
+
+// ── tombstones (shared identity with posts-api: permalink is the stable id) ────
+function readTombstones() {
+  const s = new Set();
+  if (!fs.existsSync(TOMBS)) return s;
+  for (const line of fs.readFileSync(TOMBS, 'utf8').trim().split('\n').filter(Boolean)) {
+    try { const t = JSON.parse(line); if (t.permalink) s.add(t.permalink); if (t.id) s.add(t.id); } catch { /* skip */ }
+  }
+  return s;
+}
+
+// ── cache ─────────────────────────────────────────────────────────────────────
+function readCache() {
+  try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); }
+  catch { return { account: null, cached_at: null, complete: false, byId: {} }; }
+}
+function writeCache(c) {
+  fs.writeFileSync(CACHE, JSON.stringify(c));
+}
+
+let REFRESH_LOCK = false;
+
+/**
+ * Paginate the account's /media feed newest-first into the cache.
+ *  - default (incremental): walk pages until we hit an id we already have, or maxPages.
+ *  - { full:true }: walk the entire history (cap 260 pages ≈ 6,500 posts).
+ * Returns { added, scanned, complete }.
+ */
+async function refreshLiveMedia({ full = false, maxPages = full ? 260 : 24 } = {}) {
+  if (!hasCreds()) throw new Error('IG_USER_ID + IG_ACCESS_TOKEN not set (simulation mode)');
+  if (REFRESH_LOCK) throw new Error('a refresh is already running');
+  REFRESH_LOCK = true;
+  const token = process.env.IG_ACCESS_TOKEN;
+  const uid = process.env.IG_USER_ID;
+  const cache = readCache();
+  const known = new Set(Object.keys(cache.byId));
+  let added = 0, scanned = 0, pages = 0, hitKnown = false;
+  let url = `https://${HOST}/${VER}/${uid}/media?fields=${encodeURIComponent(FIELDS)}&limit=50&access_token=${token}`;
+  try {
+    // account identity (cheap, refreshes counts)
+    try {
+      const a = await getJSON(`https://${HOST}/${VER}/${uid}?fields=username,name,media_count,followers_count&access_token=${token}`);
+      if (a && !a.error) cache.account = { username: a.username, name: a.name, media_count: a.media_count, followers_count: a.followers_count };
+    } catch { /* keep prior */ }
+
+    while (url && pages < maxPages) {
+      const j = await getJSON(url);
+      if (j.error) throw new Error(`Graph: ${j.error.message}`);
+      const rows = j.data || [];
+      pages++;
+      for (const m of rows) {
+        scanned++;
+        if (!cache.byId[m.id]) added++;
+        cache.byId[m.id] = {
+          id: m.id,
+          caption: m.caption || '',
+          type: m.media_type || 'IMAGE',
+          thumb: m.thumbnail_url || m.media_url || '',
+          permalink: m.permalink || '',
+          ts: m.timestamp || '',
+          likes: m.like_count ?? null,
+          comments: m.comments_count ?? null,
+        };
+      }
+      // incremental early-stop: this page was entirely posts we already had
+      if (!full && rows.length && rows.every((m) => known.has(m.id))) { hitKnown = true; break; }
+      url = (j.paging && j.paging.next) || '';
+    }
+    cache.cached_at = new Date().toISOString();
+    if (full && !url) cache.complete = true; // reached the end of history
+    writeCache(cache);
+    return { added, scanned, pages, complete: !!cache.complete, hitKnown };
+  } finally { REFRESH_LOCK = false; }
+}
+
+// ── grouping for the viewer ─────────────────────────────────────────────────
+function dayLabel(iso, today) {
+  const d = new Date(iso);
+  const key = d.toISOString().slice(0, 10);
+  const t0 = new Date(today); t0.setHours(0, 0, 0, 0);
+  const diff = Math.round((t0 - new Date(key + 'T00:00:00')) / 86400000);
+  let label;
+  if (diff === 0) label = 'Today';
+  else if (diff === 1) label = 'Yesterday';
+  else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
+  return { key, label };
+}
+
+function getLivePosts({ days = 0, limit = 0 } = {}) {
+  const cache = readCache();
+  const tombs = readTombstones();
+  const all = Object.values(cache.byId)
+    .filter((p) => p.permalink && !tombs.has(p.permalink) && !tombs.has(p.id))
+    .sort((a, b) => (b.ts || '').localeCompare(a.ts || ''));
+  const now = new Date();
+  const cutoff = days > 0 ? now.getTime() - days * 86400000 : 0;
+  let rows = cutoff ? all.filter((p) => new Date(p.ts).getTime() >= cutoff) : all;
+  if (limit > 0) rows = rows.slice(0, limit);
+  // group by day
+  const groups = new Map();
+  for (const p of rows) {
+    const { key, label } = dayLabel(p.ts, now);
+    if (!groups.has(key)) groups.set(key, { date: key, label, posts: [] });
+    groups.get(key).posts.push(p);
+  }
+  return {
+    account: cache.account,
+    cached_at: cache.cached_at,
+    complete: !!cache.complete,
+    cached_total: Object.keys(cache.byId).length,
+    shown: rows.length,
+    liveEnabled: /^(1|true|yes|on)$/i.test(process.env.IG_LIVE_DELETE || ''),
+    days: [...groups.values()],
+  };
+}
+
+// ── routes ────────────────────────────────────────────────────────────────────
+function registerLiveRoutes(app, agentName = 'instagram-agent') {
+  app.get('/live', (req, res) => {
+    if (!fs.existsSync(VIEWER)) return res.status(404).send('live-viewer.html missing');
+    res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
+  });
+
+  app.get('/api/live/posts', (req, res) => {
+    try {
+      const days = parseInt(req.query.days, 10) || 0;
+      const limit = parseInt(req.query.limit, 10) || 0;
+      res.json(getLivePosts({ days, limit }));
+    } catch (e) { res.status(500).json({ error: e.message }); }
+  });
+
+  app.post('/api/live/refresh', async (req, res) => {
+    try {
+      const full = /^(1|true|yes|on)$/i.test(String((req.body && req.body.full) || req.query.full || ''));
+      const r = await refreshLiveMedia({ full });
+      res.json({ ok: true, ...r, cached_at: new Date().toISOString() });
+    } catch (e) {
+      const busy = /already running/.test(e.message);
+      res.status(busy ? 409 : 500).json({ ok: false, error: e.message });
+    }
+  });
+}
+
+module.exports = { registerLiveRoutes, refreshLiveMedia, getLivePosts };
diff --git a/agents/instagram-agent/public/live-viewer.html b/agents/instagram-agent/public/live-viewer.html
new file mode 100644
index 0000000..8854e2c
--- /dev/null
+++ b/agents/instagram-agent/public/live-viewer.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>Designer Wallcoverings — Instagram Posts</title>
+<style>
+:root{
+  --bg:#0c0f1a;--s:#131829;--e:#1c2237;--t:#f0f0f5;--m:#a1a1b5;--mut:#6b7280;
+  --p:#10b981;--ph:#059669;--r:#f43f5e;--amber:#f59e0b;--bd:#2a3048;--card:210px;
+}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--t);font:15px/1.45 -apple-system,system-ui,Segoe UI,sans-serif}
+a{color:var(--p);text-decoration:none}
+header{position:sticky;top:0;z-index:20;background:var(--s);border-bottom:1px solid var(--bd);padding:12px 16px;display:flex;flex-direction:column;gap:10px}
+.row1{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+h1{font-size:16px;margin:0;font-weight:650;display:flex;gap:8px;align-items:center}
+.sub{color:var(--m);font-size:12.5px}
+.grow{flex:1}
+button,input,select{font-family:inherit}
+input,select{background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:8px;padding:7px 10px;font-size:13px}
+input:focus,select:focus{outline:none;border-color:var(--p)}
+.btn{background:var(--e);color:var(--t);border:1px solid var(--bd);border-radius:8px;padding:7px 12px;font-size:12.5px;cursor:pointer}
+.btn:hover{border-color:var(--p)}
+.btn.primary{background:var(--p);color:#04120c;border-color:var(--p);font-weight:650}
+.range{display:flex;align-items:center;gap:6px;color:var(--m);font-size:12px}
+input[type=range]{accent-color:var(--p)}
+.seg{display:inline-flex;border:1px solid var(--bd);border-radius:8px;overflow:hidden}
+.seg button{background:var(--e);color:var(--m);border:0;padding:7px 11px;font-size:12.5px;cursor:pointer}
+.seg button.on{background:var(--p);color:#04120c;font-weight:650}
+.seg button.on.live{background:var(--r);color:#fff}
+.warn{background:#3a1620;border:1px solid var(--r);color:#ffd7de;border-radius:8px;padding:8px 12px;font-size:12.5px;display:none}
+.warn.show{display:block}
+.warn b{color:#fff}
+main{padding:14px 16px 80px}
+.day{margin:22px 0 8px;display:flex;align-items:baseline;gap:10px;border-bottom:1px solid var(--bd);padding-bottom:6px}
+.day h2{font-size:14px;margin:0;font-weight:650}
+.day .c{color:var(--m);font-size:12px}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card),1fr));gap:12px;margin-top:10px}
+.card{position:relative;background:var(--s);border:1px solid var(--bd);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;transition:opacity .25s,transform .25s}
+.card.gone{opacity:0;transform:scale(.94);pointer-events:none}
+.thumb{aspect-ratio:1;background:#0a0d17 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:12px}
+.badge{position:absolute;top:8px;left:8px;background:#000a;color:#fff;font-size:10.5px;padding:2px 7px;border-radius:20px;text-transform:capitalize}
+.meta{padding:9px 10px 10px;display:flex;flex-direction:column;gap:6px;flex:1}
+.cap{font-size:12px;color:var(--m);max-height:3.2em;overflow:hidden}
+.stats{font-size:11.5px;color:var(--mut);display:flex;gap:10px}
+.acts{display:flex;gap:6px;margin-top:auto}
+.acts a{font-size:11.5px;color:var(--m)}
+.del{margin-left:auto;background:transparent;color:var(--r);border:1px solid var(--bd);border-radius:7px;padding:4px 9px;font-size:11.5px;cursor:pointer}
+.del:hover{background:var(--r);color:#fff;border-color:var(--r)}
+.del[disabled]{opacity:.5;cursor:progress}
+.empty{color:var(--m);text-align:center;padding:60px 0}
+.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--e);border:1px solid var(--bd);border-radius:10px;padding:10px 16px;font-size:13px;z-index:50;display:none}
+.toast.show{display:block}.toast.err{border-color:var(--r);color:#ffd7de}
+</style></head>
+<body>
+<header>
+  <div class="row1">
+    <h1>📸 Designer Wallcoverings — Instagram</h1>
+    <span class="sub" id="acct">loading…</span>
+    <span class="grow"></span>
+    <button class="btn" id="refresh" title="Fetch new posts since last cache">↻ Refresh</button>
+    <button class="btn" id="backfill" title="Paginate the entire post history into the cache">⤓ Backfill all</button>
+  </div>
+  <div class="row1">
+    <input id="q" placeholder="Search caption…" style="min-width:180px">
+    <div class="seg" id="range">
+      <button data-days="1" class="on">Today</button>
+      <button data-days="7">7 days</button>
+      <button data-days="30">30 days</button>
+      <button data-days="0">All</button>
+    </div>
+    <select id="sort">
+      <option value="new">Newest first</option>
+      <option value="old">Oldest first</option>
+      <option value="likes">Most liked</option>
+      <option value="comments">Most commented</option>
+    </select>
+    <label class="range">density
+      <input type="range" id="density" min="130" max="320" value="210">
+    </label>
+    <span class="grow"></span>
+    <span class="sub">Delete mode</span>
+    <div class="seg" id="mode">
+      <button data-live="0" class="on">Hide (safe)</button>
+      <button data-live="1">Delete on IG</button>
+    </div>
+  </div>
+  <div class="warn" id="warn"><b>Delete-on-IG is armed.</b> Clicking Delete drives the logged-in browser and <b>permanently removes the post from Instagram</b> (serialized, verified). This cannot be undone.</div>
+  <div class="warn" id="warnoff" style="background:#12241a;border-color:var(--p);color:#c7f5df"><b>Safe mode.</b> Delete only hides the post from this board — Instagram is not touched.</div>
+</header>
+<main><div id="out" class="empty">Loading…</div></main>
+<div class="toast" id="toast"></div>
+
+<script>
+const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
+let STATE={days:1,sort:'new',q:'',live:0,data:null};
+
+// persist controls
+try{const p=JSON.parse(localStorage.getItem('iglive')||'{}');Object.assign(STATE,p);}catch{}
+$('#density').value = localStorage.getItem('igdensity')||210;
+document.documentElement.style.setProperty('--card', ($('#density').value)+'px');
+
+function toast(msg,err){const t=$('#toast');t.textContent=msg;t.className='toast show'+(err?' err':'');clearTimeout(t._t);t._t=setTimeout(()=>t.className='toast',3200);}
+function save(){localStorage.setItem('iglive',JSON.stringify({days:STATE.days,sort:STATE.sort,live:STATE.live}));}
+
+function applyMode(){
+  $$('#mode button').forEach(b=>b.classList.toggle('on', +b.dataset.live===STATE.live));
+  const liveBtn=$('#mode button[data-live="1"]'); liveBtn.classList.toggle('live', STATE.live===1);
+  $('#warn').classList.toggle('show', STATE.live===1 && STATE.data && STATE.data.liveEnabled);
+  $('#warnoff').classList.toggle('show', STATE.live===0);
+}
+
+async function load(){
+  const r=await fetch('/api/live/posts?days='+STATE.days);
+  STATE.data=await r.json();
+  const d=STATE.data;
+  if(d.account){$('#acct').innerHTML=`@${d.account.username} · ${(d.account.media_count||0).toLocaleString()} posts · ${(d.account.followers_count||0).toLocaleString()} followers · <span class="sub">cache: ${d.cached_total.toLocaleString()}${d.complete?'':' (partial — Backfill all)'}${d.cached_at?' · '+new Date(d.cached_at).toLocaleString():''}</span>`;}
+  if(STATE.live===1 && !d.liveEnabled){toast('Delete-on-IG is turned off server-side (IG_LIVE_DELETE). Falling back to safe mode.'); STATE.live=0;}
+  applyMode(); render();
+}
+
+function render(){
+  const d=STATE.data; const out=$('#out'); if(!d){return;}
+  const q=STATE.q.trim().toLowerCase();
+  let days=(d.days||[]).map(g=>({...g,posts:g.posts.filter(p=>!q||(p.caption||'').toLowerCase().includes(q))})).filter(g=>g.posts.length);
+  const cmp={new:(a,b)=>(b.ts||'').localeCompare(a.ts||''),old:(a,b)=>(a.ts||'').localeCompare(b.ts||''),likes:(a,b)=>(b.likes||0)-(a.likes||0),comments:(a,b)=>(b.comments||0)-(a.comments||0)}[STATE.sort];
+  days.forEach(g=>g.posts.sort(cmp));
+  if(STATE.sort==='old') days=days.slice().reverse();
+  const total=days.reduce((n,g)=>n+g.posts.length,0);
+  if(!total){out.className='empty';out.innerHTML=d.cached_total?'No posts match.':'Cache is empty — click <b>↻ Refresh</b> to pull the latest posts.';return;}
+  out.className='';
+  out.innerHTML=days.map(g=>`
+    <div class="day"><h2>${g.label}</h2><span class="c">${g.posts.length} post${g.posts.length>1?'s':''}</span></div>
+    <div class="grid">${g.posts.map(card).join('')}</div>`).join('');
+  $$('.del').forEach(b=>b.onclick=()=>del(b));
+}
+
+function card(p){
+  const t=(p.type||'').toLowerCase();
+  const style=p.thumb?`style="background-image:url('${p.thumb.replace(/'/g,'')}')"`:'';
+  return `<div class="card" data-perma="${encodeURIComponent(p.permalink)}" data-id="${p.id}">
+    <div class="thumb" ${style}>${p.thumb?'':'no preview'}<span class="badge">${t.replace('_album','')}</span></div>
+    <div class="meta">
+      <div class="cap">${(p.caption||'').replace(/</g,'&lt;').slice(0,140)||'<span style="color:var(--mut)">no caption</span>'}</div>
+      <div class="stats">♥ ${p.likes??'—'} · 💬 ${p.comments??'—'} · ${new Date(p.ts).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}</div>
+      <div class="acts"><a href="${p.permalink}" target="_blank" rel="noopener noreferrer">open ↗</a>
+        <button class="del">Delete</button></div>
+    </div></div>`;
+}
+
+async function del(btn){
+  const cardEl=btn.closest('.card');
+  const permalink=decodeURIComponent(cardEl.dataset.perma);
+  const id=cardEl.dataset.id;
+  const live=STATE.live===1;
+  if(live && !confirm('Permanently DELETE this post from Instagram? This cannot be undone.')) return;
+  btn.disabled=true; btn.textContent=live?'deleting…':'hiding…';
+  try{
+    const r=await fetch('/api/posts/delete',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({permalink,media_id:id,live,handle:'designerwallcoverings'})});
+    const j=await r.json();
+    if(j.ok){cardEl.classList.add('gone');setTimeout(()=>cardEl.remove(),260);toast(live?'Deleted on Instagram ✓':'Hidden from board ✓');}
+    else{btn.disabled=false;btn.textContent='Delete';toast(j.error||'delete failed',true);}
+  }catch(e){btn.disabled=false;btn.textContent='Delete';toast(e.message,true);}
+}
+
+// controls
+$('#q').oninput=e=>{STATE.q=e.target.value;render();};
+$('#sort').value=STATE.sort; $('#sort').onchange=e=>{STATE.sort=e.target.value;save();render();};
+$('#density').oninput=e=>{document.documentElement.style.setProperty('--card',e.target.value+'px');localStorage.setItem('igdensity',e.target.value);};
+$$('#range button').forEach(b=>{b.classList.toggle('on',+b.dataset.days===STATE.days);b.onclick=()=>{STATE.days=+b.dataset.days;$$('#range button').forEach(x=>x.classList.remove('on'));b.classList.add('on');save();load();};});
+$$('#mode button').forEach(b=>b.onclick=()=>{STATE.live=+b.dataset.live;save();applyMode();});
+async function refresh(full){const btn=full?$('#backfill'):$('#refresh');const o=btn.textContent;btn.textContent=full?'backfilling…':'refreshing…';btn.disabled=true;
+  try{const r=await fetch('/api/live/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({full})});const j=await r.json();
+    if(j.ok){toast(`+${j.added} new · scanned ${j.scanned}${j.complete?' · full history':''}`);await load();}else{toast(j.error||'refresh failed',true);}}
+  catch(e){toast(e.message,true);}finally{btn.textContent=o;btn.disabled=false;}}
+$('#refresh').onclick=()=>refresh(false);
+$('#backfill').onclick=()=>{if(confirm('Paginate the ENTIRE ~5,500-post history into the cache? (~110 API calls, one-time)'))refresh(true);};
+
+// init day-seg from state
+$$('#range button').forEach(b=>b.classList.toggle('on',+b.dataset.days===STATE.days));
+load();
+</script>
+</body></html>
diff --git a/agents/instagram-agent/server.js b/agents/instagram-agent/server.js
index 932df7d..9488053 100644
--- a/agents/instagram-agent/server.js
+++ b/agents/instagram-agent/server.js
@@ -127,6 +127,20 @@ const { app, start, scheduler } = createAgentServer({
         }
       },
     },
+    {
+      name: 'live-media-refresh',
+      schedule: '7 * * * *', // hourly at :07 — pull new posts from the DW feed into the /live cache
+      fn: async () => {
+        if (!liveMode()) return;
+        try {
+          const { refreshLiveMedia } = require('./live-media');
+          const r = await refreshLiveMedia({ full: false });
+          console.log(`[${AGENT_NAME}] Cron live-media refresh: +${r.added} new (scanned ${r.scanned})`);
+        } catch (err) {
+          console.error(`[${AGENT_NAME}] Cron live-media refresh error:`, err.message);
+        }
+      },
+    },
     {
       name: 'report-to-pulse',
       schedule: '0 */4 * * *', // every 4 hours
@@ -179,4 +193,9 @@ require('./posts-api').registerPostRoutes(app, AGENT_NAME);
 // not in post-ledger.jsonl, so /posts can't show them — this board can, with live status.
 require('./spoonflower-api').registerSpoonflowerRoutes(app);
 
+// "All the daily Instagram posts" board (GET /live) — the DW account's REAL feed from the
+// Graph API, grouped by day, delete-any (reuses posts-api's /api/posts/delete path).
+// Page views read the cache only; a cron + the Refresh button paginate /media into it.
+require('./live-media').registerLiveRoutes(app, AGENT_NAME);
+
 start();

← db59445 auto-data-snapshot: 2026-08-18T11:17:58 (1 data files) — age  ·  back to Norma  ·  instagram-agent: gitignore .env* (holds live IG token) 2900a0c →