← back to Slack Idea Board
slack-idea-board: 3-column Slack-feed idea triage (link/about · fit+why · next step), local Ollama enrichment
edba999362eb490908ec720f6ccd838506ae4393 · 2026-07-13 00:35:57 -0700 · Steve Abrams
Files touched
A .gitignoreA public/index.htmlA server.js
Diff
commit edba999362eb490908ec720f6ccd838506ae4393
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 13 00:35:57 2026 -0700
slack-idea-board: 3-column Slack-feed idea triage (link/about · fit+why · next step), local Ollama enrichment
---
.gitignore | 4 ++
public/index.html | 93 ++++++++++++++++++++++++++++++++++++++++
server.js | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 223 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e05ae32
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+data/
+*.log
+.DS_Store
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..f57c194
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Idea Board · Slack feed</title>
+<style>
+ :root{--bg:#0f1117;--panel:#161922;--line:#262b38;--text:#e6e8ee;--dim:#9aa3b2;--gold:#c9a227;
+ --yes:#2ecc71;--maybe:#e0b000;--no:#e05a5a;--chip:#1d2230;}
+ *{box-sizing:border-box}
+ body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+ header{position:sticky;top:0;z-index:5;background:linear-gradient(#0f1117,#0f1117ee);border-bottom:1px solid var(--line);
+ padding:14px 20px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+ h1{font-size:16px;margin:0;letter-spacing:.3px}
+ h1 b{color:var(--gold)}
+ .meta{color:var(--dim);font-size:12px}
+ .spacer{flex:1}
+ .live{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
+ .dot{width:8px;height:8px;border-radius:50%;background:var(--yes);box-shadow:0 0 0 0 var(--yes);animation:p 2s infinite}
+ @keyframes p{0%{box-shadow:0 0 0 0 rgba(46,204,113,.5)}70%{box-shadow:0 0 0 7px rgba(46,204,113,0)}100%{box-shadow:0 0 0 0 rgba(46,204,113,0)}}
+ table{width:100%;border-collapse:collapse}
+ thead th{position:sticky;top:57px;background:#12151d;text-align:left;font-size:11px;letter-spacing:.08em;
+ text-transform:uppercase;color:var(--dim);padding:10px 16px;border-bottom:1px solid var(--line)}
+ tbody td{padding:14px 16px;border-bottom:1px solid var(--line);vertical-align:top}
+ tbody tr:hover{background:#12151d}
+ .col1{width:38%} .col2{width:30%} .col3{width:32%}
+ a.link{color:#7db4ff;text-decoration:none;font-weight:600;word-break:break-word}
+ a.link:hover{text-decoration:underline}
+ .about{color:var(--text);margin-top:5px}
+ .note{color:var(--dim);font-style:italic;margin-top:4px;font-size:12.5px}
+ .when{color:var(--dim);font-size:11px;margin-top:6px}
+ .chan{display:inline-block;font-size:10px;color:var(--dim);border:1px solid var(--line);border-radius:999px;padding:1px 7px;margin-left:6px}
+ .fit{display:inline-block;font-weight:700;border-radius:6px;padding:2px 9px;font-size:12px}
+ .fit.Yes{background:rgba(46,204,113,.15);color:var(--yes)} .fit.Maybe{background:rgba(224,176,0,.15);color:var(--maybe)}
+ .fit.No{background:rgba(224,90,90,.15);color:var(--no)} .fit\.\?{background:var(--chip);color:var(--dim)}
+ .reason{margin-top:6px;color:var(--text)}
+ .step{display:inline-block;border-radius:6px;padding:3px 10px;font-size:12px;font-weight:600;border:1px solid var(--line);background:var(--chip)}
+ .step.build{border-color:#3b6fd0;color:#8fb4ff} .step.skill{border-color:#8a5cd0;color:#c3a6ff}
+ .step.agent{border-color:#c9a227;color:#e6c65a} .step.skip{color:var(--dim)}
+ .detail{margin-top:6px;color:var(--dim)}
+ .pending{color:var(--dim);font-style:italic}
+ .empty{padding:40px;text-align:center;color:var(--dim)}
+</style>
+</head>
+<body>
+<header>
+ <h1><b>Idea Board</b> — Slack feed triage</h1>
+ <span class="meta" id="stat">loading…</span>
+ <span class="spacer"></span>
+ <span class="live"><span class="dot"></span> live · auto-refresh 20s</span>
+</header>
+<table>
+ <thead><tr>
+ <th class="col1">1 · Link & what the idea is</th>
+ <th class="col2">2 · Good for your builds? Why</th>
+ <th class="col3">3 · Next step</th>
+ </tr></thead>
+ <tbody id="rows"><tr><td colspan="3" class="empty">Loading your feed…</td></tr></tbody>
+</table>
+<script>
+const stepClass = s => /build new project/i.test(s)?'build':/skill/i.test(s)?'skill':/agent/i.test(s)?'agent':'skip';
+const esc = s => (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+function fmt(iso){try{return new Date(iso).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch{return iso}}
+async function load(){
+ try{
+ const r = await fetch('/api/ideas'); const d = await r.json();
+ document.getElementById('stat').textContent =
+ `${d.count} ideas · ${d.pending} analyzing · model ${d.model}`;
+ const rows = document.getElementById('rows');
+ if(!d.ideas.length){ rows.innerHTML='<tr><td colspan=3 class=empty>No links posted yet. Drop an x.com/GitHub link in #claude-to-steve or #claude-chat.</td></tr>'; return; }
+ rows.innerHTML = d.ideas.map(i=>{
+ const host = (()=>{try{return new URL(i.url).hostname.replace('www.','')}catch{return i.url}})();
+ const c2 = i.pending ? '<span class="pending">analyzing…</span>'
+ : `<span class="fit ${i.fit}">${esc(i.fit)}</span><div class="reason">${esc(i.fit_reason)}</div>`;
+ const c3 = i.pending ? '<span class="pending">…</span>'
+ : `<span class="step ${stepClass(i.next_step)}">${esc(i.next_step)}</span><div class="detail">${esc(i.next_detail)}</div>`;
+ return `<tr>
+ <td class="col1">
+ <a class="link" href="${esc(i.url)}" target="_blank" rel="noopener noreferrer">${esc(i.title||host)} ↗</a>
+ <span class="chan">${esc(i.channel)}</span>
+ <div class="about">${esc(i.about||i.text||'')}</div>
+ ${i.note?`<div class="note">“${esc(i.note.slice(0,140))}”</div>`:''}
+ <div class="when">🕓 ${fmt(i.when)}</div>
+ </td>
+ <td class="col2">${c2}</td>
+ <td class="col3">${c3}</td>
+ </tr>`;
+ }).join('');
+ }catch(e){ document.getElementById('stat').textContent='error: '+e.message; }
+}
+load(); setInterval(load, 20000);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..7f45c0c
--- /dev/null
+++ b/server.js
@@ -0,0 +1,126 @@
+// slack-idea-board — polls Steve's Slack idea feed(s) and renders a 3-column
+// triage board: [1] link + what the idea is · [2] good for your builds? why ·
+// [3] next step (Build new project / Add new skill with repo / Create agent).
+// AI enrichment is LOCAL Ollama ($0). Zero external deps (Node built-in http).
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const DIR = __dirname;
+const ENV = loadEnv('/Users/macstudio3/Projects/slack-to-steve/.env'); // reuse the bot token + channels
+const TOKEN = ENV.SLACK_BOT_TOKEN;
+const CHANNELS = (ENV.SLACK_CHANNEL_IDS || ENV.SLACK_CHANNEL_ID || '').split(',').map(s => s.trim()).filter(Boolean);
+const STEVE = ENV.STEVE_USER_ID;
+const PORT = parseInt(process.env.PORT || '9820', 10);
+const OLLAMA = process.env.OLLAMA_URL || 'http://localhost:11434';
+const MODEL = process.env.OLLAMA_MODEL || 'hermes3:8b';
+const CACHE_FILE = path.join(DIR, 'data', 'enrich.json');
+const CHAN_NAME = { 'C09SW7VQ0RK': 'claude-to-steve', 'C0BGQ2QLR35': 'claude-chat' };
+
+const BUILD_CONTEXT = `Steve builds: AI agents & Claude Code skills (vendor scrapers, automation), Designer Wallcoverings e-commerce (Shopify, catalog pipelines, dashboards), web apps, marketplaces, and LOCAL-AI pipelines (Ollama/exo). He drops links (X/Twitter posts, GitHub repos, tools, demos about AI agents, coding, automation, design) as build inspiration.`;
+
+function loadEnv(p) { const o = {}; try { for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m) o[m[1]] = m[2].replace(/^['"]|['"]$/g, ''); } } catch {} return o; }
+let CACHE = {}; try { CACHE = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch {}
+function saveCache() { try { fs.writeFileSync(CACHE_FILE, JSON.stringify(CACHE, null, 2)); } catch {} }
+
+async function slack(method, params = {}) {
+ const r = await fetch('https://slack.com/api/' + method + '?' + new URLSearchParams(params), { headers: { Authorization: 'Bearer ' + TOKEN } });
+ return r.json();
+}
+const URL_RE = /<(https?:\/\/[^>|\s]+)(?:\|[^>]*)?>|(?<![<|])(https?:\/\/[^\s|>]+)/;
+function firstUrl(m) {
+ const mt = (m.text || '').match(URL_RE); if (mt) return mt[1] || mt[2];
+ for (const a of m.attachments || []) if (a.title_link || a.original_url) return a.title_link || a.original_url;
+ for (const f of m.files || []) if (f.url_private) return f.url_private;
+ return null;
+}
+function ideaAbout(m) {
+ const a = (m.attachments || [])[0] || {};
+ const title = a.title || '';
+ const text = (a.text || '').replace(/\s+/g, ' ').slice(0, 400);
+ const raw = (m.text || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
+ return { title, text, note: raw };
+}
+
+async function ollamaEnrich(idea) {
+ const prompt = `${BUILD_CONTEXT}
+
+A new item was posted to Steve's idea feed. Assess it FOR HIS BUILDS.
+
+URL: ${idea.url}
+Link title: ${idea.title || '(none)'}
+Link description: ${idea.text || '(none)'}
+Steve's note: ${idea.note || '(none)'}
+
+Respond with ONLY a JSON object, no prose:
+{
+ "about": "<1-2 sentence plain-English summary of what this idea/link IS>",
+ "fit": "<Yes | Maybe | No>",
+ "fit_reason": "<1-2 sentences: is it good for Steve's builds and why / why not>",
+ "next_step": "<exactly one of: Build new project | Add new skill with repo | Create agent | Skip>",
+ "next_detail": "<1 sentence: concrete suggestion — e.g. a project/skill/agent name and what it does>"
+}`;
+ const r = await fetch(OLLAMA + '/api/generate', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: MODEL, prompt, stream: false, format: 'json', options: { temperature: 0.3 } }),
+ });
+ const j = await r.json();
+ const parsed = JSON.parse(j.response);
+ return {
+ about: parsed.about || idea.title || '', fit: parsed.fit || 'Maybe',
+ fit_reason: parsed.fit_reason || '', next_step: parsed.next_step || 'Skip',
+ next_detail: parsed.next_detail || '',
+ };
+}
+
+async function collectIdeas() {
+ const ideas = [];
+ for (const cid of CHANNELS) {
+ const h = await slack('conversations.history', { channel: cid, limit: '200' });
+ if (!h.ok) continue;
+ for (const m of h.messages || []) {
+ if (m.subtype || m.bot_id) continue;
+ if (STEVE && m.user !== STEVE) continue;
+ const url = firstUrl(m); if (!url) continue;
+ const about = ideaAbout(m);
+ ideas.push({ id: cid + ':' + m.ts, ts: m.ts, channel: CHAN_NAME[cid] || cid, url, ...about,
+ when: new Date(parseFloat(m.ts) * 1000).toISOString() });
+ }
+ }
+ ideas.sort((a, b) => parseFloat(b.ts) - parseFloat(a.ts));
+ return ideas;
+}
+
+let ENRICH_BUSY = false;
+async function enrichNew(ideas, cap = 6) {
+ if (ENRICH_BUSY) return; ENRICH_BUSY = true;
+ let done = 0;
+ try {
+ for (const idea of ideas) {
+ if (CACHE[idea.id]) continue;
+ if (done >= cap) break;
+ try { CACHE[idea.id] = { ...await ollamaEnrich(idea), enriched_at: new Date().toISOString() }; done++; saveCache(); }
+ catch (e) { CACHE[idea.id] = { about: idea.title || '', fit: '?', fit_reason: 'AI enrich failed: ' + e.message, next_step: 'Skip', next_detail: '', error: true }; saveCache(); }
+ }
+ } finally { ENRICH_BUSY = false; }
+}
+
+const server = http.createServer(async (req, res) => {
+ try {
+ if (req.url.startsWith('/api/ideas')) {
+ const ideas = await collectIdeas();
+ enrichNew(ideas); // fire-and-forget; cached items return immediately
+ const out = ideas.map(i => ({ ...i, ...(CACHE[i.id] || { pending: true }) }));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ count: out.length, pending: out.filter(x => x.pending).length, ideas: out, model: MODEL }));
+ }
+ const file = req.url === '/' || req.url === '' ? 'index.html' : req.url.split('?')[0].replace(/^\//, '');
+ const fp = path.join(DIR, 'public', path.basename(file));
+ if (fs.existsSync(fp)) {
+ res.writeHead(200, { 'Content-Type': file.endsWith('.html') ? 'text/html' : 'text/plain' });
+ return res.end(fs.readFileSync(fp));
+ }
+ res.writeHead(404); res.end('not found');
+ } catch (e) { res.writeHead(500); res.end(String(e.message)); }
+});
+server.listen(PORT, () => console.log(`[slack-idea-board] http://localhost:${PORT} channels=${CHANNELS.join(',')} model=${MODEL}`));
(oldest)
·
back to Slack Idea Board
·
idea-board: dedupe by URL + full pagination + Actionable/Yes 8231daf →