[object Object]

← back to Slack To Steve

slack-to-steve: pre-build two-way #claude-to-steve poller (DTD verdict A), un-armed pending read scope

de634c124e6707a48e77cb2fb952eb784e427c3a · 2026-07-12 23:01:45 -0700 · Steve Abrams

Files touched

Diff

commit de634c124e6707a48e77cb2fb952eb784e427c3a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 23:01:45 2026 -0700

    slack-to-steve: pre-build two-way #claude-to-steve poller (DTD verdict A), un-armed pending read scope
---
 .env.example                                   | 13 ++++
 .gitignore                                     |  6 ++
 README.md                                      | 51 ++++++++++++++++
 com.steve.slack-to-steve-ingest.plist.template | 20 ++++++
 ingest.mjs                                     | 85 ++++++++++++++++++++++++++
 preflight.mjs                                  | 54 ++++++++++++++++
 whoami.mjs                                     | 21 +++++++
 7 files changed, 250 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3ecca1e
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,13 @@
+# Copy to .env and fill in. The bot token is the SAME one the Slack MCP uses
+# (env SLACK_BOT_TOKEN in ~/.claude.json → mcpServers.slack.env).
+SLACK_BOT_TOKEN=xoxb-...
+# #claude-to-steve channel ID (starts with C...). Get it: right-click the channel
+# in Slack → View channel details → bottom, or run: node whoami.mjs
+SLACK_CHANNEL_ID=
+# Steve's Slack user ID (starts with U...). Sender allowlist — only his messages act.
+STEVE_USER_ID=
+# ARM SWITCH — leave 0 until Steve explicitly approves autonomous execution.
+# 0 = capture + ack + queue only (safe). 1 = auto-run each message via `claude -p`.
+AUTO_EXECUTE=0
+# Path/command for the Claude CLI used when AUTO_EXECUTE=1.
+CLAUDE_CMD=claude
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..03503d9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+.env
+data/
+logs/
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3af1fd7
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# slack-to-steve — two-way #claude-to-steve pipe
+
+Lets Steve talk to Claude **inline** in the DW Slack workspace (`designerwallc-hyg8426`,
+channel `#claude-to-steve`). Claude reads new messages Steve posts, acks in-thread, and
+(when armed) actions them and replies. The Slack analog of `george-reply-ingest` (email).
+
+**Decision of record (DTD 2026-07-12, verdict A, unanimous Claude+Codex):** commit to full
+two-way; gate only what Claude can't self-grant (the read scope + reinstall + channel create);
+pre-build the poller so it's one-switch-live. This repo is that pre-build — **un-armed**.
+
+## Current status: BLOCKED on Steve (read scope)
+The Slack bot token today has `chat:write`, `chat:write.public`, `channels:read` —
+it can **post** but **cannot read** messages (no `channels:history`) and **cannot create**
+channels (`channels:manage`). So the pipe can't go live until the steps below.
+
+## Go-live — 4 steps (only Steve can do 1–3)
+1. **Add the read scope.** Slack app config → *OAuth & Permissions* → Bot Token Scopes →
+   add **`channels:history`** (and `groups:history` if `#claude-to-steve` will be **private**).
+   Optional: **`channels:manage`** if you want Claude to auto-create channels.
+2. **Reinstall the app** to the workspace (the OAuth re-consent — your click). This mints a
+   new bot token with the added scope. If the token value changes, update `SLACK_BOT_TOKEN`
+   in `~/.claude.json` (slack MCP env) **and** this project's `.env`.
+3. **Create `#claude-to-steve`** in Slack and **invite the bot** (`/invite @<botname>`).
+   (Or, once `channels:manage` is added, Claude can create it.)
+4. **Wire + verify** (Claude does this):
+   ```sh
+   cd ~/Projects/slack-to-steve
+   cp .env.example .env            # paste SLACK_BOT_TOKEN
+   node whoami.mjs                 # prints SLACK_CHANNEL_ID; also how to get STEVE_USER_ID
+   #  → fill SLACK_CHANNEL_ID + STEVE_USER_ID in .env
+   node preflight.mjs             # must print ✅ READY
+   node ingest.mjs                # one manual poll — post a test msg in the channel first
+   ```
+   When `ingest.mjs` acks your test message in-thread, install the launchd poller:
+   ```sh
+   cp com.steve.slack-to-steve-ingest.plist.template ~/Library/LaunchAgents/com.steve.slack-to-steve-ingest.plist
+   launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.slack-to-steve-ingest.plist
+   ```
+
+## Rails (do not bypass)
+- **`AUTO_EXECUTE=0` by default** — captures + acks + queues to `data/inbox.jsonl`; a live
+  session actions the queue. Flip to `1` (auto-run each message via `claude -p`) **only** on
+  Steve's explicit approval — autonomous execution from an inbound channel is itself gated.
+- **Sender allowlist** — only `STEVE_USER_ID` messages are ever processed.
+- Cursor (`data/last-ts.txt`) guarantees no message is actioned twice.
+
+## Files
+- `preflight.mjs` — READY/BLOCKED check (has the read scope landed?)
+- `ingest.mjs` — the poller (read → verify → ack → queue → [armed] execute → reply)
+- `whoami.mjs` — resolve channel + user IDs for `.env`
+- `com.steve.slack-to-steve-ingest.plist.template` — launchd job (90s), **not installed**
diff --git a/com.steve.slack-to-steve-ingest.plist.template b/com.steve.slack-to-steve-ingest.plist.template
new file mode 100644
index 0000000..243b2f9
--- /dev/null
+++ b/com.steve.slack-to-steve-ingest.plist.template
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- TEMPLATE — NOT installed. Install only after preflight prints READY and Steve
+     approves the scheduled job. Copy to ~/Library/LaunchAgents/, then:
+       launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.slack-to-steve-ingest.plist -->
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key><string>com.steve.slack-to-steve-ingest</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>/Users/macstudio3/Projects/slack-to-steve/ingest.mjs</string>
+  </array>
+  <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/slack-to-steve</string>
+  <key>StartInterval</key><integer>90</integer>
+  <key>StandardOutPath</key><string>/Users/macstudio3/Projects/slack-to-steve/logs/out.log</string>
+  <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/slack-to-steve/logs/err.log</string>
+  <key>RunAtLoad</key><true/>
+</dict>
+</plist>
diff --git a/ingest.mjs b/ingest.mjs
new file mode 100644
index 0000000..40343e7
--- /dev/null
+++ b/ingest.mjs
@@ -0,0 +1,85 @@
+// ingest.mjs — the #claude-to-steve two-way poller (george-reply-ingest for Slack).
+//
+// Reads NEW messages Steve posts in #claude-to-steve, verifies the sender,
+// posts a threaded acknowledgement, and QUEUES the instruction to inbox.jsonl.
+//
+// SAFETY RAILS (match george-reply-ingest):
+//   • It does NOT autonomously execute instructions by default. Auto-execute via
+//     `claude -p` is the ARMED step — enabled only when AUTO_EXECUTE=1 AND a live
+//     session/operator has approved it. Until then this captures + acks + queues,
+//     so a human actions the queue. Gated/irreversible asks ALWAYS stop for approval.
+//   • Sender allowlist: only messages from STEVE_USER_ID are processed.
+//   • Cursor (last-ts) is persisted so a message is never processed twice.
+//
+// Needs scopes: channels:history (read) + chat:write (reply). $0 — Slack Web API only.
+import fs from 'fs';
+import path from 'path';
+import { execFile } from 'child_process';
+
+const DIR = process.cwd();
+const ENV = loadEnv(path.join(DIR, '.env'));
+const TOKEN = ENV.SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN;
+const CHANNEL = ENV.SLACK_CHANNEL_ID || process.env.SLACK_CHANNEL_ID;
+const STEVE = ENV.STEVE_USER_ID || process.env.STEVE_USER_ID;
+const AUTO_EXECUTE = (ENV.AUTO_EXECUTE || process.env.AUTO_EXECUTE || '0') === '1';
+const CLAUDE_CMD = ENV.CLAUDE_CMD || process.env.CLAUDE_CMD || 'claude';
+const CURSOR = path.join(DIR, 'data', 'last-ts.txt');
+const INBOX = path.join(DIR, 'data', 'inbox.jsonl');
+
+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;
+}
+async function slack(method, params) {
+  const body = new URLSearchParams(params);
+  const r = await fetch('https://slack.com/api/' + method, {
+    method: 'POST',
+    headers: { Authorization: 'Bearer ' + TOKEN, 'Content-Type': 'application/x-www-form-urlencoded' },
+    body,
+  });
+  return r.json();
+}
+const log = m => console.log(new Date().toISOString() + ' ' + m);
+
+(async () => {
+  if (!TOKEN || !CHANNEL || !STEVE) { log('CONFIG missing (SLACK_BOT_TOKEN / SLACK_CHANNEL_ID / STEVE_USER_ID) — see .env.example'); process.exit(1); }
+  fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
+  const oldest = fs.existsSync(CURSOR) ? fs.readFileSync(CURSOR, 'utf8').trim() : '0';
+
+  const hist = await slack('conversations.history', { channel: CHANNEL, oldest: oldest || '0', limit: '50', inclusive: 'false' });
+  if (!hist.ok) { log('history error: ' + hist.error + (hist.error === 'missing_scope' ? ' (run preflight — channels:history not granted yet)' : '')); process.exit(1); }
+
+  // Slack returns newest-first; process oldest-first so the cursor advances monotonically.
+  const msgs = (hist.messages || []).filter(m => m.type === 'message' && !m.subtype).reverse();
+  let newest = oldest;
+  let acted = 0;
+  for (const m of msgs) {
+    if (parseFloat(m.ts) <= parseFloat(oldest || '0')) continue;
+    newest = m.ts;
+    if (m.user !== STEVE) continue;                       // sender allowlist
+    const text = (m.text || '').trim();
+    if (!text) continue;
+
+    fs.appendFileSync(INBOX, JSON.stringify({ ts: m.ts, user: m.user, text, queued_at: new Date().toISOString(), executed: false }) + '\n');
+    acted++;
+
+    if (AUTO_EXECUTE) {
+      await slack('chat.postMessage', { channel: CHANNEL, thread_ts: m.ts, text: '⏳ On it — working on this now.' });
+      runClaude(text).then(out => slack('chat.postMessage', { channel: CHANNEL, thread_ts: m.ts, text: clip(out) }))
+                     .catch(e => slack('chat.postMessage', { channel: CHANNEL, thread_ts: m.ts, text: '⚠️ ' + e.message }));
+    } else {
+      await slack('chat.postMessage', { channel: CHANNEL, thread_ts: m.ts, text: '📥 Got it — queued for Claude. (auto-execute is off; a session will action this.)' });
+    }
+  }
+  if (parseFloat(newest) > parseFloat(oldest || '0')) fs.writeFileSync(CURSOR, newest);
+  log(`processed ${acted} new message(s) from Steve; cursor=${newest}`);
+})();
+
+function clip(s) { s = String(s || '').trim(); return s.length > 3500 ? s.slice(0, 3500) + '\n…(truncated)' : (s || '(no output)'); }
+function runClaude(prompt) {
+  return new Promise((resolve, reject) => {
+    execFile(CLAUDE_CMD, ['-p', prompt], { timeout: 240000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) =>
+      err ? reject(new Error((stderr || err.message).slice(0, 400))) : resolve(stdout));
+  });
+}
diff --git a/preflight.mjs b/preflight.mjs
new file mode 100644
index 0000000..fd9cc58
--- /dev/null
+++ b/preflight.mjs
@@ -0,0 +1,54 @@
+// preflight.mjs — is the two-way pipe ready to go live?
+// Checks: token valid, channel resolvable, and whether the READ scope
+// (channels:history) has been granted yet. Prints READY / BLOCKED with the gap.
+// $0 — hits Slack Web API only (auth.test + one conversations.history probe).
+import fs from 'fs';
+import path from 'path';
+
+const ENV = loadEnv(path.join(process.cwd(), '.env'));
+const TOKEN = ENV.SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN;
+const CHANNEL = ENV.SLACK_CHANNEL_ID || process.env.SLACK_CHANNEL_ID || '';
+
+function loadEnv(p) {
+  const o = {};
+  try {
+    for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
+      const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+      if (m) o[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+    }
+  } catch {}
+  return o;
+}
+async function slack(method, params = {}) {
+  const url = 'https://slack.com/api/' + method + '?' + new URLSearchParams(params);
+  const r = await fetch(url, { headers: { Authorization: 'Bearer ' + TOKEN } });
+  return r.json();
+}
+
+(async () => {
+  if (!TOKEN) { console.log('BLOCKED — no SLACK_BOT_TOKEN (copy .env.example → .env)'); process.exit(1); }
+  const auth = await slack('auth.test');
+  if (!auth.ok) { console.log('BLOCKED — token invalid: ' + auth.error); process.exit(1); }
+  console.log(`token OK — bot @${auth.user} on team ${auth.team} (${auth.team_id})`);
+
+  // WRITE check is implicit (chat:write present). READ check = probe history.
+  let readOK = false, readErr = null;
+  if (CHANNEL) {
+    const h = await slack('conversations.history', { channel: CHANNEL, limit: 1 });
+    readOK = !!h.ok;
+    readErr = h.error || null;
+  } else {
+    readErr = 'no SLACK_CHANNEL_ID set';
+  }
+
+  if (readOK) {
+    console.log('READ OK — channels:history is granted and the channel is readable.');
+    console.log('\n✅ READY — run `node ingest.mjs` (or install the launchd plist) to go live.');
+  } else if (readErr === 'missing_scope') {
+    console.log('READ BLOCKED — token still lacks channels:history.');
+    console.log('\n⛔ NOT READY — Steve must add channels:history to the Slack app + reinstall (see README).');
+  } else {
+    console.log('READ BLOCKED — ' + readErr + (readErr === 'not_in_channel' ? ' (invite the bot to the channel)' : ''));
+    console.log('\n⛔ NOT READY — resolve the above, then re-run preflight.');
+  }
+})();
diff --git a/whoami.mjs b/whoami.mjs
new file mode 100644
index 0000000..04cc493
--- /dev/null
+++ b/whoami.mjs
@@ -0,0 +1,21 @@
+// whoami.mjs — resolve the #claude-to-steve channel ID and Steve's user ID,
+// so .env can be filled without hunting through Slack. $0 (Web API only).
+// Uses only channels:read + users:read (already granted / lightweight).
+import fs from 'fs';
+import path from 'path';
+const ENV = (() => { const o = {}; try { for (const l of fs.readFileSync(path.join(process.cwd(), '.env'), 'utf8').split('\n')) { const m = l.match(/^([A-Z0-9_]+)=(.*)$/); if (m) o[m[1]] = m[2].replace(/^['"]|['"]$/g, ''); } } catch {} return o; })();
+const TOKEN = ENV.SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN;
+const NAME = process.argv[2] || 'claude-to-steve';
+async function slack(m, p = {}) { const r = await fetch('https://slack.com/api/' + m + '?' + new URLSearchParams(p), { headers: { Authorization: 'Bearer ' + TOKEN } }); return r.json(); }
+(async () => {
+  if (!TOKEN) { console.log('set SLACK_BOT_TOKEN in .env first'); process.exit(1); }
+  let cursor = '', found = null;
+  do {
+    const r = await slack('conversations.list', { limit: '200', types: 'public_channel,private_channel', cursor });
+    if (!r.ok) { console.log('channel list error: ' + r.error); break; }
+    found = (r.channels || []).find(c => c.name === NAME);
+    cursor = r.response_metadata?.next_cursor || '';
+  } while (cursor && !found);
+  console.log(found ? `#${NAME} → SLACK_CHANNEL_ID=${found.id}` : `#${NAME} not found — create it in Slack + invite the bot`);
+  console.log('\nTo find Steve\'s user ID: Slack → his profile → ⋯ → Copy member ID (U...).');
+})();

(oldest)  ·  back to Slack To Steve  ·  slack-to-steve: multi-channel poller — watch both #claude-ch 0bd337e →