[object Object]

← back to Norma

IG auto-post viewer with inline delete (GET /posts on :9810)

8529d5a158740988d86b0e13f383e92f10313215 · 2026-08-17 07:14:08 -0700 · steve

- GET /posts: per-account grid of all 275 auto-posts (post-ledger.jsonl),
  sort + density + account filter + search, created date+time on each card
- Inline delete, two modes: tombstone (remove from viewer/ledger, no IG touch)
  and live (openclaw drives Chrome to delete on IG + verify gone, serialized)
- GET /api/posts, GET /api/posts/oc-status, POST /api/posts/delete
- Routes registered before start()'s 404 catch-all, behind existing Basic Auth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8529d5a158740988d86b0e13f383e92f10313215
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 17 07:14:08 2026 -0700

    IG auto-post viewer with inline delete (GET /posts on :9810)
    
    - GET /posts: per-account grid of all 275 auto-posts (post-ledger.jsonl),
      sort + density + account filter + search, created date+time on each card
    - Inline delete, two modes: tombstone (remove from viewer/ledger, no IG touch)
      and live (openclaw drives Chrome to delete on IG + verify gone, serialized)
    - GET /api/posts, GET /api/posts/oc-status, POST /api/posts/delete
    - Routes registered before start()'s 404 catch-all, behind existing Basic Auth
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 agents/instagram-agent/posts-api.js             | 179 ++++++++++++++++++++++
 agents/instagram-agent/public/posts-viewer.html | 190 ++++++++++++++++++++++++
 agents/instagram-agent/server.js                |   7 +
 3 files changed, 376 insertions(+)

diff --git a/agents/instagram-agent/posts-api.js b/agents/instagram-agent/posts-api.js
new file mode 100644
index 0000000..e992be7
--- /dev/null
+++ b/agents/instagram-agent/posts-api.js
@@ -0,0 +1,179 @@
+/**
+ * posts-api.js — the auto-posting viewer + inline delete for norma-instagram (:9810).
+ *
+ * Surfaces every post Norma auto-published (data/post-ledger.jsonl, ~275 rows across
+ * 29 accounts) as a browsable, per-account grid at GET /posts, and supports deleting
+ * any post INLINE from that grid in two honest modes:
+ *
+ *   mode="tombstone" (default, safe):  removes the post from Norma's viewer + ledger by
+ *      appending it to data/deleted-posts.jsonl. Instagram is NOT touched. Zero Meta
+ *      automation-ban risk. Use to curate what the viewer shows / what Norma tracks.
+ *
+ *   mode="live" (destructive):  actually deletes the post ON Instagram. The Graph API
+ *      cannot delete a published IG post (see delete-originals.js), so this drives the
+ *      logged-in real Chrome via openclaw (··· → Delete → confirm → verify gone), then
+ *      tombstones it. Requires openclaw Chrome logged in as the OWNING account; if the
+ *      Delete affordance isn't reachable it fails loudly and does NOT tombstone. Live
+ *      deletes are SERIALIZED (one at a time) — rapid fleet-wide browser deletes are
+ *      Meta's #1 automation-ban trigger.
+ *
+ * Routes registered on the caller's express app (before start()'s 404 catch-all):
+ *   GET  /posts                 → the viewer HTML (public/posts-viewer.html)
+ *   GET  /api/posts             → { accounts:[...], posts:[...] } (tombstones filtered out)
+ *   GET  /api/posts/oc-status   → { ok, loggedIn } openclaw/Instagram login probe (for Live mode)
+ *   POST /api/posts/delete      → { permalink, media_id, live } → delete inline
+ */
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const DIR = __dirname;
+const DATA = path.join(DIR, 'data');
+const LEDGER = path.join(DATA, 'post-ledger.jsonl');
+const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
+const VIEWER = path.join(DIR, 'public', 'posts-viewer.html');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// ── ledger + tombstone helpers ───────────────────────────────────────────────
+function readJsonl(file) {
+  if (!fs.existsSync(file)) return [];
+  return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean)
+    .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+}
+
+// Stable identity for a post: permalink is unique per IG post; fall back to media_id or a
+// handle+ts synthetic so rows with neither can still be tombstoned.
+function postId(p) { return p.permalink || p.media_id || `${p.handle || '?'}::${p.ts || ''}`; }
+
+function readTombstones() {
+  const s = new Set();
+  for (const t of readJsonl(TOMBS)) { if (t.id) s.add(t.id); }
+  return s;
+}
+
+function getPosts() {
+  const tombs = readTombstones();
+  const seen = new Set();
+  const posts = [];
+  // newest first; dedupe by id (a permalink can only meaningfully appear once)
+  for (const p of readJsonl(LEDGER).reverse()) {
+    const id = postId(p);
+    if (tombs.has(id) || seen.has(id)) continue;
+    seen.add(id);
+    posts.push({
+      id,
+      ts: p.ts || null,
+      handle: p.handle || '?',
+      page_name: p.page_name || p.handle || '?',
+      title: (p.product_title || '').split('|')[0].trim() || p.product_handle || '(untitled)',
+      image: p.image_url || (typeof p.images === 'string' ? p.images.split(',')[0] : '') || '',
+      permalink: p.permalink || '',
+      media_id: p.media_id || '',
+      kind: p.kind || 'IMAGE',
+      product_handle: p.product_handle || '',
+    });
+  }
+  const byAcct = {};
+  for (const p of posts) {
+    (byAcct[p.handle] ||= { handle: p.handle, page_name: p.page_name, count: 0 });
+    byAcct[p.handle].count++;
+  }
+  const accounts = Object.values(byAcct).sort((a, b) => a.handle.localeCompare(b.handle));
+  return { accounts, posts, total: posts.length };
+}
+
+function tombstone(entry, extra = {}) {
+  fs.appendFileSync(TOMBS, JSON.stringify({
+    id: entry.id, permalink: entry.permalink || '', media_id: entry.media_id || '',
+    handle: entry.handle || '', ts: new Date().toISOString(), ...extra,
+  }) + '\n');
+}
+
+// ── openclaw live-delete (mirrors delete-originals.js; the only way to delete on IG) ──
+function oc(cmd) {
+  return execSync(`openclaw browser ${cmd}`, { encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
+}
+let TAB = null;
+function ocOpen(url) { const o = oc(`open ${JSON.stringify(url)} --timeout 30000`); TAB = (o.match(/id:\s*([A-F0-9]+)/i) || [])[1] || TAB; return TAB; }
+function ocNav(url) { if (!TAB) return ocOpen(url); oc(`navigate ${JSON.stringify(url)} --target-id ${TAB}`); }
+function ocSnap() { try { return oc(`snapshot --format ai --limit 800 ${TAB ? `--target-id ${TAB}` : ''}`); } catch { return ''; } }
+function ocFindRef(snap, re) {
+  for (const line of String(snap).split('\n')) {
+    if (re.test(line)) { const m = line.match(/\[ref=([A-Za-z0-9_]+)\]/); if (m) return m[1]; }
+  }
+  return null;
+}
+function ocClick(ref) { oc(`click ${ref} --target-id ${TAB}`); }
+
+let LIVE_LOCK = false; // serialize live deletes — never run two browser deletes at once
+
+async function ocLoggedIn() {
+  try {
+    ocOpen('https://www.instagram.com/'); await sleep(2500);
+    const s = ocSnap();
+    if (/Log in|Log In|Phone number, username/i.test(s) && !/Home|Search|Profile/i.test(s)) return false;
+    return true;
+  } catch { return false; }
+}
+
+async function liveDelete(permalink) {
+  if (!permalink) throw new Error('no permalink to delete');
+  ocNav(permalink); await sleep(2500);
+  const more = ocFindRef(ocSnap(), /More options|More$/i);
+  if (!more) throw new Error('no ··· menu — Chrome is not logged in as this account');
+  ocClick(more); await sleep(1200);
+  const del = ocFindRef(ocSnap(), /\bDelete\b/);
+  if (!del) throw new Error('no Delete item — logged-in account does not own this post');
+  ocClick(del); await sleep(1200);
+  const confirm = ocFindRef(ocSnap(), /\bDelete\b/);
+  if (!confirm) throw new Error('no confirm Delete button appeared');
+  ocClick(confirm); await sleep(3000);
+  ocNav(permalink); await sleep(2500);
+  return /isn't available|Sorry, this page|Page Not Found/i.test(ocSnap());
+}
+
+// ── route registration ───────────────────────────────────────────────────────
+function registerPostRoutes(app, agentName = 'instagram-agent') {
+  app.get('/posts', (req, res) => {
+    if (!fs.existsSync(VIEWER)) return res.status(404).send('posts-viewer.html missing');
+    res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
+  });
+
+  app.get('/api/posts', (req, res) => {
+    try { res.json(getPosts()); }
+    catch (e) { res.status(500).json({ error: e.message }); }
+  });
+
+  app.get('/api/posts/oc-status', async (req, res) => {
+    try { res.json({ ok: true, loggedIn: await ocLoggedIn() }); }
+    catch (e) { res.json({ ok: false, loggedIn: false, error: e.message }); }
+  });
+
+  app.post('/api/posts/delete', async (req, res) => {
+    const { permalink = '', media_id = '', live = false } = req.body || {};
+    const id = permalink || media_id;
+    if (!id) return res.status(400).json({ ok: false, error: 'permalink or media_id required' });
+    const entry = { id, permalink, media_id, handle: (req.body && req.body.handle) || '' };
+
+    if (!live) {
+      // tombstone only — remove from viewer/ledger, never touch Instagram
+      try { tombstone(entry, { mode: 'tombstone' }); return res.json({ ok: true, mode: 'tombstone' }); }
+      catch (e) { return res.status(500).json({ ok: false, error: e.message }); }
+    }
+
+    // live delete — serialized, verified, tombstone ONLY on confirmed removal
+    if (LIVE_LOCK) return res.status(409).json({ ok: false, error: 'a live delete is already running — one at a time' });
+    LIVE_LOCK = true;
+    try {
+      const gone = await liveDelete(permalink);
+      if (gone) { tombstone(entry, { mode: 'live', verified: true }); return res.json({ ok: true, mode: 'live', verified: true }); }
+      return res.json({ ok: false, mode: 'live', verified: false, error: 'clicked Delete but post still resolves — not tombstoned' });
+    } catch (e) {
+      console.error(`[${agentName}] live delete failed: ${e.message}`);
+      return res.status(502).json({ ok: false, mode: 'live', error: e.message });
+    } finally { LIVE_LOCK = false; }
+  });
+}
+
+module.exports = { registerPostRoutes, getPosts };
diff --git a/agents/instagram-agent/public/posts-viewer.html b/agents/instagram-agent/public/posts-viewer.html
new file mode 100644
index 0000000..6739d80
--- /dev/null
+++ b/agents/instagram-agent/public/posts-viewer.html
@@ -0,0 +1,190 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Norma IG — Auto-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:220px;
+}
+*{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}
+.count{color:var(--m);font-size:13px}
+.grow{flex:1}
+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)}
+.range{display:flex;align-items:center;gap:6px;color:var(--m);font-size:12px}
+input[type=range]{accent-color:var(--p)}
+.modewrap{display:flex;align-items:center;gap:8px;font-size:13px}
+.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}
+.oc{font-size:12px;color:var(--m)}
+.oc .ok{color:var(--p)}.oc .bad{color:var(--r)}
+main{padding:14px 16px 60px}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card),1fr));gap:12px}
+.card{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{width:100%;aspect-ratio:1;object-fit:cover;background:var(--e);display:block}
+.thumb.ph{display:flex;align-items:center;justify-content:center;color:var(--mut);font-size:12px}
+.body{padding:9px 10px;display:flex;flex-direction:column;gap:6px;flex:1}
+.acct{font-size:11px;color:var(--p);font-weight:600;text-transform:lowercase;letter-spacing:.2px}
+.ttl{font-size:12.5px;line-height:1.35;max-height:52px;overflow:hidden}
+.when{font-size:11px;color:var(--m);display:flex;align-items:center;gap:4px}
+.acts{display:flex;gap:6px;padding:0 10px 10px}
+.acts a,.acts button{flex:1;text-align:center;font-size:12px;padding:7px 6px;border-radius:8px;border:1px solid var(--bd);cursor:pointer}
+.open{background:var(--e);color:var(--t)}
+.del{background:transparent;color:var(--r);border-color:var(--r);font-weight:600}
+.del:hover{background:var(--r);color:#fff}
+.card.busy{opacity:.6;pointer-events:none}
+.err{padding:0 10px 10px;font-size:11.5px;color:#ffd7de}
+.err .fix{display:flex;gap:6px;margin-top:6px}
+.err .fix a,.err .fix button{flex:1;font-size:11px;padding:5px;border-radius:6px;border:1px solid var(--bd);background:var(--e);color:var(--t);cursor:pointer}
+.empty{color:var(--m);padding:40px;text-align:center}
+.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;opacity:0;transition:opacity .2s}
+.toast.show{opacity:1}.toast.good{border-color:var(--p)}.toast.bad{border-color:var(--r)}
+</style></head><body>
+<header>
+  <div class="row1">
+    <h1>📸 Norma Auto-Posts <span class="count" id="count">loading…</span></h1>
+    <span class="grow"></span>
+    <input id="q" placeholder="search title / account…" style="min-width:190px">
+    <select id="acct"><option value="">All accounts</option></select>
+    <select id="sort">
+      <option value="new">Newest</option>
+      <option value="old">Oldest</option>
+      <option value="acct">Account A→Z</option>
+      <option value="title">Title A→Z</option>
+    </select>
+    <span class="range">density <input type="range" id="dens" min="150" max="340" step="10"></span>
+  </div>
+  <div class="row1">
+    <span class="modewrap">Delete mode:
+      <span class="seg">
+        <button id="mList" data-mode="tombstone">Remove from list</button>
+        <button id="mLive" data-mode="live" class="live">Delete on Instagram</button>
+      </span>
+    </span>
+    <span class="oc" id="oc"></span>
+    <span class="grow"></span>
+  </div>
+  <div class="warn" id="warn">
+    <b>Live mode:</b> the trash button will actually delete the post <b>on Instagram</b> (irreversible) by
+    driving your logged-in Chrome via openclaw. Deletes run one at a time and only work on the account
+    Chrome is logged into. If the Delete option isn't reachable, nothing is removed.
+  </div>
+</header>
+<main><div class="grid" id="grid"></div><div class="empty" id="empty" style="display:none">No posts.</div></main>
+<div class="toast" id="toast"></div>
+
+<script>
+const $=s=>document.querySelector(s);
+const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const LS={get:(k,d)=>{try{return localStorage.getItem('igposts.'+k)??d}catch{return d}},set:(k,v)=>{try{localStorage.setItem('igposts.'+k,v)}catch{}}};
+let ALL=[], MODE=LS.get('mode','tombstone');
+
+function fmtWhen(t){ if(!t) return '—'; const d=new Date(t); return isNaN(d)?esc(t):d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
+
+function toast(msg,cls){ const el=$('#toast'); el.textContent=msg; el.className='toast show '+(cls||''); setTimeout(()=>el.className='toast',2600); }
+
+function applyMode(){
+  $('#mList').classList.toggle('on',MODE==='tombstone');
+  $('#mLive').classList.toggle('on',MODE==='live');
+  $('#warn').classList.toggle('show',MODE==='live');
+  if(MODE==='live') probeOc(); else $('#oc').textContent='';
+}
+async function probeOc(){
+  $('#oc').innerHTML='checking Chrome login…';
+  try{ const r=await fetch('api/posts/oc-status').then(r=>r.json());
+    $('#oc').innerHTML=r.loggedIn?'Chrome: <span class="ok">logged into Instagram ✓</span> (deletes the currently-active account’s posts)':'Chrome: <span class="bad">not logged into Instagram</span> — log in via openclaw first';
+  }catch{ $('#oc').innerHTML='Chrome: <span class="bad">openclaw unreachable</span>'; }
+}
+
+function view(){
+  const q=$('#q').value.trim().toLowerCase();
+  const acct=$('#acct').value, sort=$('#sort').value;
+  let rows=ALL.filter(p=>(!acct||p.handle===acct)&&(!q||(p.title+' '+p.handle+' '+p.page_name).toLowerCase().includes(q)));
+  rows.sort((a,b)=>{
+    if(sort==='new') return (b.ts||'').localeCompare(a.ts||'');
+    if(sort==='old') return (a.ts||'').localeCompare(b.ts||'');
+    if(sort==='acct') return a.handle.localeCompare(b.handle)||(b.ts||'').localeCompare(a.ts||'');
+    if(sort==='title') return a.title.localeCompare(b.title);
+    return 0;
+  });
+  const g=$('#grid');
+  g.innerHTML=rows.map(p=>{
+    const img=p.image?`<img class="thumb" loading="lazy" src="${esc(p.image)}" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'thumb ph',textContent:'no image'}))">`:`<div class="thumb ph">no image</div>`;
+    const open=p.permalink?`<a class="open" href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">Open ↗</a>`:`<span class="open" style="opacity:.4">no link</span>`;
+    return `<div class="card" data-id="${esc(p.id)}">
+      ${img}
+      <div class="body">
+        <div class="acct">@${esc(p.handle)}</div>
+        <div class="ttl">${esc(p.title)}</div>
+        <div class="when">🕓 ${fmtWhen(p.ts)}</div>
+      </div>
+      <div class="acts">${open}<button class="del" onclick="del(this)">🗑 Delete</button></div>
+    </div>`;
+  }).join('');
+  $('#empty').style.display=rows.length?'none':'block';
+  $('#count').textContent=`${rows.length} shown · ${ALL.length} total · ${new Set(ALL.map(p=>p.handle)).size} accounts`;
+}
+
+async function del(btn){
+  const card=btn.closest('.card'); const id=card.dataset.id;
+  const p=ALL.find(x=>x.id===id); if(!p) return;
+  const live=MODE==='live';
+  const ok=confirm(live
+    ? `DELETE ON INSTAGRAM (permanent):\n\n@${p.handle}\n${p.title}\n\nThis drives Chrome to delete the live post. Continue?`
+    : `Remove from this viewer & Norma's ledger?\n\n@${p.handle}\n${p.title}\n\n(Does NOT delete it on Instagram.)`);
+  if(!ok) return;
+  card.classList.add('busy');
+  card.querySelectorAll('.err').forEach(e=>e.remove());
+  try{
+    const r=await fetch('api/posts/delete',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({permalink:p.permalink,media_id:p.media_id,handle:p.handle,live})}).then(r=>r.json());
+    if(r.ok){
+      card.classList.add('gone');
+      setTimeout(()=>{ ALL=ALL.filter(x=>x.id!==id); view(); },260);
+      toast(live?'Deleted on Instagram ✓':'Removed from list ✓','good');
+    }else{ throw new Error(r.error||'delete failed'); }
+  }catch(e){
+    card.classList.remove('busy');
+    const div=document.createElement('div'); div.className='err';
+    div.innerHTML=`⚠ ${esc(e.message)}<div class="fix">`+
+      (p.permalink?`<a href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">Open in IG to delete</a>`:``)+
+      `<button onclick="forceRemove('${esc(id)}')">Remove from list anyway</button></div>`;
+    card.querySelector('.acts').after(div);
+    toast('Delete failed','bad');
+  }
+}
+async function forceRemove(id){
+  const p=ALL.find(x=>x.id===id); if(!p) return;
+  await fetch('api/posts/delete',{method:'POST',headers:{'Content-Type':'application/json'},
+    body:JSON.stringify({permalink:p.permalink,media_id:p.media_id,handle:p.handle,live:false})});
+  ALL=ALL.filter(x=>x.id!==id); view(); toast('Removed from list ✓','good');
+}
+
+async function load(){
+  const d=await fetch('api/posts').then(r=>r.json());
+  ALL=d.posts||[];
+  const sel=$('#acct');
+  sel.innerHTML='<option value="">All accounts</option>'+(d.accounts||[]).map(a=>`<option value="${esc(a.handle)}">@${esc(a.handle)} (${a.count})</option>`).join('');
+  view();
+}
+
+// wire controls + restore prefs
+['q','acct','sort'].forEach(id=>$('#'+id).addEventListener('input',view));
+$('#sort').value=LS.get('sort','new'); $('#sort').addEventListener('change',()=>LS.set('sort',$('#sort').value));
+const dens=$('#dens'); dens.value=LS.get('dens','220'); document.documentElement.style.setProperty('--card',dens.value+'px');
+dens.addEventListener('input',()=>{document.documentElement.style.setProperty('--card',dens.value+'px');LS.set('dens',dens.value);});
+$('#mList').addEventListener('click',()=>{MODE='tombstone';LS.set('mode',MODE);applyMode();});
+$('#mLive').addEventListener('click',()=>{MODE='live';LS.set('mode',MODE);applyMode();});
+applyMode(); load();
+</script></body></html>
diff --git a/agents/instagram-agent/server.js b/agents/instagram-agent/server.js
index 87082d9..095bdf6 100644
--- a/agents/instagram-agent/server.js
+++ b/agents/instagram-agent/server.js
@@ -168,4 +168,11 @@ app.post('/api/skill/story', async (req, res) => {
   }
 });
 
+// ──────────────────────────────────────
+// Auto-posting viewer + inline delete (GET /posts). Reads data/post-ledger.jsonl,
+// deletes inline in "tombstone" (remove from viewer/ledger) or "live" (openclaw
+// deletes on Instagram + verifies) mode. Registered BEFORE start()'s 404 catch-all.
+// ──────────────────────────────────────
+require('./posts-api').registerPostRoutes(app, AGENT_NAME);
+
 start();

← 06c151b docs(sessions): resume — outage recovery, pm2 reconcile (TK-  ·  back to Norma  ·  Gate live IG delete behind IG_LIVE_DELETE flag (default off, ec91871 →