Skip to content

Slack module: manage TaskYou from Slack (two-way) - #33

Merged
bborn merged 5 commits into
mainfrom
proposal/slack-module
Jun 9, 2026
Merged

Slack module: manage TaskYou from Slack (two-way)#33
bborn merged 5 commits into
mainfrom
proposal/slack-module

Conversation

@bborn

@bborn bborn commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

From TaskYou task #3723 ("Explore taskyou MCP sidecar extension for Slack"). Implements a two-way Slack module so people can manage TaskYou from Slack — and includes the design rationale in docs/plans/2026-06-08-slack-module-design.md.

Slack @mention / DM ─▶ slack-bridge ─▶ classify intent ─▶ ty create/execute/input
notifications.jsonl ─▶ slack-bridge ─▶ chat.postMessage  (blocked / completed / failed)

What's here

  • modules/slack/slack-bridge.mjs — a zero-dependency Node daemon (raw fetch to the Slack + Anthropic HTTP APIs and the global WebSocket, exactly like the Linear poller — no npm install). Runs on the agents server next to ty + notifications.jsonl:
    • Outbound: tails notifications.jsonl (the same file the hooks + Claude Code channel use) → chat.postMessage. Tasks created/run from Slack answer back in their thread; everything else goes to SLACK_NOTIFY_CHANNEL.
    • Inbound: Slack Socket Mode (no public URL) → allowlist check → intent classification (Anthropic API, with a built-in keyword heuristic fallback when no key) → ty create/execute/input/list. Replies in-thread.
  • modules/slack/slack-bridge.test.mjsnode --test unit tests for the pure logic (11 cases, all green).
  • templates/ty-slack.service.tmpl — systemd user service (long-running; Socket Mode holds a WebSocket).
  • setup.sh wiring — server + exe.dev install the ty-slack service; local mode renders the files + .env. Gated behind SLACK_ENABLED=true.
  • config.example.env Slack block + README modules entry + modules/slack/README.md.

Design refinement vs the original plan

The plan said "copy taskyou-channel.ts + runRemote()." During build it became clear the bridge runs on the server next to ty and notifications.jsonl, so it reads the file and runs ty directly — no SSH/runRemote. And it's Node .mjs (the server has Node; bun is a GM-machine prereq), matching modules/linear. Scope: single bot / single operator (per ty-email); per-GM routing and the hosted-remote-MCP path remain out of scope.

Testing

  • node --test in modules/slack/11/11 pass.
  • node --check on the bridge → clean.
  • Hermetic QA harness (qa/run-qa.sh) against this branch → 17/17 pass (my setup.sh changes don't regress the channel/hook/notification flow).
  • Focused sandbox run of setup.sh server (local mode) with SLACK_ENABLED=true → bridge + .env install correctly; caught and fixed a SLACK_PROJECT_MAP double-brace bug in the heredoc default.
  • Not testable here (needs real credentials): live Slack round-trip and the Anthropic classifier path. Manual steps are in modules/slack/README.md.

Files

modules/slack/{slack-bridge.mjs,slack-bridge.test.mjs,README.md}, templates/ty-slack.service.tmpl, setup.sh, config.example.env, README.md, docs/plans/2026-06-08-slack-module-design.md — +1238.

🤖 Generated with Claude Code

bborn and others added 4 commits June 9, 2026 08:10
From TaskYou task #3723. Proposes modules/slack/, modeled on
modules/linear/linear-poll.mjs: watch a Slack channel/DM/@mention,
classify intent, drive ty, post replies, and tail notifications.jsonl
to push task.blocked/completed back to the channel. Enabled via
SLACK_ENABLED. Separates the chat-control bridge (recommended) from the
harder hosted/remote-MCP path (deferred to the credential-proxy work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
notifications.jsonl survives as the push substrate, but the channels
refactor changes three things: copy taskyou-channel.ts (bun/TS) instead
of linear-poll.mjs; reuse assigned_gm (#32 + workflow#561) for per-GM
routing (answers the shared-bot vs per-user question); adopt the
IS_LOCAL/SSH runRemote pattern + OS-aware hooks dir (#31). Adds a
dependencies section and merge order (#28 -> #31/#32 -> Slack).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#28 (channels) and #31 (local/macOS) are merged to main, so the design
is concrete: a Slack module is taskyou-channel.ts with its two ends
re-pointed — reuse pollNotifications() + runRemote() verbatim; net-new is
just a Slack adapter + LLM classifier. Ship outbound (hook -> Slack
webhook) first, inbound control second. Removes the assigned_gm / per-GM
scoping dependency (out of scope: single bot, single operator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds the Slack integration end to end (outbound + inbound), per the
design in docs/plans/2026-06-08-slack-module-design.md.

modules/slack/slack-bridge.mjs — zero-dependency Node daemon (raw fetch +
global WebSocket, like the Linear poller). Runs on the agents server next
to ty + notifications.jsonl:
  • Outbound: tails notifications.jsonl → chat.postMessage (blocked/
    completed/failed/started). Slack-originated tasks answer in-thread;
    everything else goes to SLACK_NOTIFY_CHANNEL.
  • Inbound: Slack Socket Mode (no public URL) → allowlist check →
    intent classification (Anthropic API, with a keyword-heuristic
    fallback when no key) → ty create/execute/input/list. Replies in-thread.

Also: unit tests for the pure logic (node --test, 11 cases), a
README, the ty-slack systemd service template, and setup.sh wiring
(server + exe.dev install the service; local mode renders files + .env)
plus SLACK_* in config.example.env and the README modules list. The
module is gated behind SLACK_ENABLED=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bborn
bborn force-pushed the proposal/slack-module branch from 194487f to 1584275 Compare June 9, 2026 13:17
@bborn bborn changed the title Design: Slack module for TaskYou-OS (manage TaskYou from Slack) Slack module: manage TaskYou from Slack (two-way) Jun 9, 2026
@bborn

bborn commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

QA evidence

All run against the pushed commit 1584275 on proposal/slack-module. Sandbox paths below are throwaway temp dirs under the QA harness (HOME is sandboxed — real ty/GMs untouched).

Summary

Check Result
node --check slack-bridge.mjs ✅ no syntax errors
node --test (pure-logic unit tests) 11/11 pass
qa/run-qa.sh (hermetic harness: real setup.sh + channel + hook→notification e2e) 17/17 pass
setup.sh server (local) with SLACK_ENABLED=true → bridge + .env install ✅ correct; SLACK_PROJECT_MAP valid JSON

Not covered (needs real credentials): the live Slack round-trip and the Anthropic classifier path. Manual steps are in modules/slack/README.md. The classifier degrades to the keyword heuristic (unit-tested) when no key is set.

1) node --check + unit tests (node --test)
### node --check modules/slack/slack-bridge.mjs
OK — no syntax errors

### node --test (unit tests for pure logic)
ok 1 - stripMention removes the bot mention and trims
ok 2 - isAllowed enforces the allowlist
ok 3 - mapChannelToProject matches by name, #name, or id, else default
ok 4 - extractTaskId handles #N, JSON, and id: forms
ok 5 - parseIntentResponse extracts JSON from fences and prose
ok 6 - heuristicIntent: execute when a thread/task id + run verb
ok 7 - heuristicIntent: status questions
ok 8 - heuristicIntent: reply in a task thread becomes input
ok 9 - heuristicIntent: default is create, detects 'run it'
ok 10 - heuristicIntent: empty/help
ok 11 - formatNotification renders each event type
# tests 11
# pass 11
# fail 0
2) Hermetic QA harness — qa/run-qa.sh (17/17)

This is the repo's own setup/channel harness (mandated by CLAUDE.md for setup changes). It runs the real setup.sh against a sandbox HOME, so it proves my setup.sh edits don't regress the existing channel / hook / notification flow.

TaskYouOS QA harness
────────────────────────────────────────────────────────
  ref:      HEAD
  ty:       /…/bin/ty  (0.3.2)
────────────────────────────────────────────────────────
  ✓ worktree of HEAD created

[1] setup.sh local — render GM + channel
  ✓ setup.sh local exited 0
  ✓ channel/taskyou-channel.ts rendered
  ✓ .mcp.json rendered
  ✓ alias includes channel flag
  ✓ channel deps installed (bun)
  ✓ no unresolved placeholders
  ✓ local-mode branch present (PR #31)

[2] channel smoke-test (handshake + capabilities + tools)
  ✓ smoke-test PASSED
      ✓ claude/channel capability
      ✓ tools capability
      ✓ instructions
      ✓ ty_command tool
      ✓ ssh_command tool

[3] setup.sh server — local mode (hooks + ty project, no SSH/systemd)
  ✓ setup.sh server (local) exited 0
  ✓ hooks installed to OS-correct dir: …/home/Library/Application Support/task/hooks
  ✓ hooks NOT mis-placed in ~/.config (macOS fix works)
  ✓ notifications.jsonl created
  ✓ ty project 'qa-test' registered in SANDBOX ty
  ✓ sandbox ty db is separate from real ~/.local/share/task

[4:steady] channel notification e2e (real hook → channel push)
  ✓ notification e2e (steady) PASSED
      ✓ [steady] channel pushed completed event for qa-9999
      ✓ did not replay pre-existing notifications on startup
      ✓ title with embedded quotes survived hook→channel

[4:cold] channel notification e2e (real hook → channel push)
  ✓ notification e2e (cold) PASSED
      ✓ [cold] channel pushed completed event for qa-9999
      ✓ first event after empty-file start was delivered (cold-start fix)
      ✓ title with embedded quotes survived hook→channel

────────────────────────────────────────────────────────
  RESULT: 17 passed, 0 failed
────────────────────────────────────────────────────────
3) setup.sh server (local mode) with SLACK_ENABLED=true — bridge + .env install

Fake tokens. Note SLACK_PROJECT_MAP renders as valid single-brace JSON — this is the regression the sandbox run caught and the fix verifies (${SLACK_PROJECT_MAP:-{}} previously produced a stray double }}).

==> Setting up Slack integration (local)
  ✓ slack-bridge installed: …/home/scripts/slack
  ! Local mode: start it with 'node …/home/scripts/slack/slack-bridge.mjs' (or a launchd agent).

installed file:
  slack-bridge.mjs

rendered ~/scripts/slack/.env:
  SLACK_BOT_TOKEN=xoxb-FAKE
  SLACK_APP_TOKEN=xapp-FAKE
  SLACK_NOTIFY_CHANNEL=#taskyou
  SLACK_ALLOWED_USERS=U012ABC,U034DEF
  SLACK_PROJECT_MAP={"#eng":"qa-test"}
  DEFAULT_PROJECT=qa-test
  TY_PATH=/…/bin/ty
  NOTIFICATIONS_FILE=…/home/notifications.jsonl
  ANTHROPIC_API_KEY=
  ANTHROPIC_MODEL=claude-haiku-4-5-20251001

JSON.parse(SLACK_PROJECT_MAP) round-trips:
  parsed: {"#eng":"qa-test"}

rendered ty-slack.service (NODE_BIN baked in; %h left for systemd to resolve at runtime):
  ExecStart=/…/node %h/scripts/slack/slack-bridge.mjs
  WorkingDirectory=%h/scripts/slack
  Restart=on-failure

Classifier no longer requires a separate ANTHROPIC_API_KEY: it prefers the
already-authenticated on-box `claude` CLI (claude -p — the same primitive the
GM/executors use), falls back to the Anthropic API if a key is set, then a
keyword heuristic. Override via SLACK_CLASSIFIER.

Loop / token-burn hardening (per review):
- claude -p sandboxed per call: --strict-mcp-config --mcp-config {} +
  --disallowedTools … keep it to a single turn (no agentic spiral);
  --max-budget-usd (default $0.05) hard-caps cost; timeout + maxBuffer; no retry.
- Self-loop guards: ignore own/bot/edited messages (bot_id/subtype/BOT_USER_ID).
- Concurrency cap (SLACK_MAX_CONCURRENT, default 3) bounds in-flight
  classifications; excess messages declined, not queued.
- Socket generation guard: only the newest connection's handlers stay live, so
  a reconnect race can't double-deliver events.
- Outbound poller: per-tick cap (SLACK_MAX_NOTIFS_PER_POLL, default 25) with
  byte-accurate gradual drain; only complete lines consumed (partial hook line
  held); lost/corrupt state skips backlog instead of replaying.

Tests: +2 (buildClassifierContext, readNewChunk partial-line/rotation) → 13/13.
Docs: README "Runaway / cost protection", config knobs, design-doc note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bborn

bborn commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Runaway-loop / token-burn review (commit e15e6cf)

Two changes here: (1) the classifier now defaults to the on-box claude CLI (claude.ai login — no separate API key, same primitive the GM/executors use), API-key path optional; (2) a hardening pass against loops and runaway spend, since classification costs tokens.

Risks considered → mitigation

Risk Mitigation
Bot replies to itself → infinite reply→classify→reply loop Ignore own/bot/edited/system messages (bot_id / subtype / BOT_USER_ID). Our replies carry bot_id, so the loop can't start even if auth.test failed at boot.
claude -p spirals into a multi-turn agentic run / burns tokens Per call: --strict-mcp-config --mcp-config '{"mcpServers":{}}' (no MCP servers) + --disallowedTools Bash,Read,Write,…single turn, no tools; --max-budget-usd (default $0.05) hard cost cap; wall-clock timeout + capped buffer; one attempt, no retry (failure → fall back).
Burst of messages → unbounded concurrent subprocesses/spend SLACK_MAX_CONCURRENT (default 3) in-flight cap; excess messages declined, not queued.
Slack event retries re-trigger classification Envelope acked in <3s (stops Slack resending) + event_id dedup.
Socket reconnect race → two sockets double-deliver events Monotonic generation guard — only the newest connection's handlers stay live.
Notification flood / lost state → Slack post storm or history replay Per-tick cap SLACK_MAX_NOTIFS_PER_POLL (default 25) with byte-accurate gradual drain; only complete lines consumed (partial hook line held); null/corrupt cursor skips the backlog instead of replaying.

Trust boundary unchanged: only SLACK_ALLOWED_USERS can drive ty at all.

Proof

Unit tests — 13/13 (added buildClassifierContext + readNewChunk partial-line/rotation):

ok 12 - buildClassifierContext includes thread + open tasks + message
ok 13 - readNewChunk: only complete lines; partial last line is held back
# tests 13
# pass 13
# fail 0

Live hardened claude -p classification (exact flags the bridge uses), message "go with option 2":

is_error: false   num_turns: 1   total_cost_usd: 0.0157717   (cap: 0.05)
result: ```json { "action": "help", … } ```   ← single turn, under budget, JSON parses

num_turns: 1 confirms no agentic spiral; cost is well under the per-call ceiling.

QA harness (qa/run-qa.sh) against this commit → 17/17 (no setup regression).

Still credential-gated, so not exercised here: the live Slack round-trip and the API-key classifier path. The claude -p path itself is proven above.

@bborn
bborn merged commit 00a894a into main Jun 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant