Implementation Plan: TinyClaw Slack Channel Adapter
Status: Draft Research Doc: docs/plans/tinyclaw-slack-research-2026-03-09.md Author: Terraphim AI Date: 2026-03-09 Estimated Effort: 8-10 hours (public repo), 6-8 hours (twin-slack in private repo)
Overview
Summary
Add a Slack channel adapter to terraphim_tinyclaw following the existing Channel trait pattern.
The adapter uses slack-morphism for Socket Mode (WebSocket) connectivity with bot self-detection,
@mention stripping, user name caching, and message dedup. Testing uses the private twin-slack
digital twin -- no mock objects, no Slack tokens in CI.
Approach
Mechanical extension: copy the Telegram adapter pattern, replace teloxide calls with slack-morphism
equivalents. Feature-gated behind slack = ["dep:slack-morphism"]. The api_base_url override
approach from research is NOT viable because slack-morphism hardcodes SLACK_API_URI_STR. Instead,
the testing seam is a SlackApiClient trait that wraps the three API calls we need (auth.test,
chat.postMessage, users.info), with a production implementation using slack-morphism and a
twin-compatible implementation using plain reqwest in the private test harness.
Scope
In Scope:
SlackChannelstruct implementingChanneltraitSlackConfigstruct with validation- Socket Mode event listener (message.im + app_mention)
- Bot self-detection via auth.test
- @mention stripping from incoming text
- User name resolution with in-memory cache
- Message dedup via event ID tracking
- Markdown-to-Slack-mrkdwn formatting
- Message chunking at 4000 chars
- Feature flag and Cargo.toml changes
- Outgoing message queue with retry-on-failure (from NanoClaw cross-check)
is_from_memetadata on InboundMessage for bot message tracking (from NanoClaw cross-check)- Unit tests (all logic, no network, public CI)
- Integration test scaffold (#[ignore], env-var-gated)
Out of Scope:
- Thread reply support
- Slash command handling
- Block Kit rich formatting
- Reaction-based status indicators
- File/media upload
- Multi-workspace support
- Channel access policies
- Channel metadata sync
Avoid At All Cost (5/25 analysis):
- Block Kit message builder (over-engineering for text-only MVP)
- Slack interactive components (buttons, modals)
- OAuth installation flow (single-workspace bot, manual token setup)
- Unfurling / link previews
- Slack app manifest auto-provisioning
- Custom slash command framework
- Workspace-level admin features
- Message editing / deletion handling
- Presence / online status
- Custom emoji handling
Architecture
Component Diagram
terraphim_tinyclaw (public, open source)
+---------------------------------------------------------------+
| |
| SlackConfig ----> SlackChannel ----> Channel trait |
| | | | |
| | +-------+-------+-------+ |
| | | | | | |
| v v v v v |
| validate() start() stop() send() is_running() |
| | | |
| +-------+------+ +-----+------+ |
| | | | | |
| SocketMode auth.test chat.postMessage |
| listener | | |
| | bot_user_id chunk_message() |
| v | mrkdwn_format() |
| message event v |
| | filter_own() |
| v | |
| strip_mention() | |
| | | |
| v | |
| resolve_user_name() (cache) |
| | |
| v |
| dedup_event() |
| | |
| v |
| InboundMessage --> MessageBus |
| |
+---------------------------------------------------------------+
zestic-ai/digital-twins (PRIVATE)
+---------------------------------------------------------------+
| twin-slack |
| auth.test --> returns configured bot_user_id |
| chat.postMessage --> stores in DashMapStore |
| users.info --> returns configurable profiles |
| (Phase 2: WebSocket Socket Mode simulation) |
+---------------------------------------------------------------+Data Flow
INBOUND:
[Slack workspace] --(Socket Mode WS)--> [slack-morphism listener]
--> [event handler: dedup, check allowlist]
--> [is_own_message? -> set is_from_me=true in metadata, still forward]
--> [strip @mention, resolve user name]
--> [InboundMessage::new("slack", sender_id, chat_id, cleaned_text)]
--> [MessageBus.inbound_tx]
OUTBOUND:
[MessageBus.outbound_rx] --> [ChannelManager.send()]
--> [SlackChannel.send(OutboundMessage)]
--> [markdown_to_slack_mrkdwn()]
--> [chunk_message(4000)]
--> [chat.postMessage per chunk]
--> success: done
--> failure: push to outgoing_queue, log warning
--> [Slack workspace]
OUTGOING QUEUE (resilience):
[on reconnect / periodic flush]
--> [drain outgoing_queue]
--> [chat.postMessage per queued item]Key Design Decisions
| Decision | Rationale | Alternatives Rejected |
|----------|-----------|----------------------|
| slack-morphism for Slack API | Only mature Rust Slack library with Socket Mode | Raw reqwest (too much boilerplate), @slack/bolt via FFI (absurd) |
| Socket Mode (not HTTP Events) | TinyClaw is a local binary, no public endpoint | HTTP Events API requires public URL, TLS, webhook verification |
| In-memory user name cache | Simple, sufficient for single-user bot | Database cache (over-engineering), no cache (API rate limit risk) |
| HashSet for event dedup | Simple, bounded by session lifetime | LRU cache (premature), database (over-engineering) |
| Outgoing queue + retry | NanoClaw pattern: queue on disconnect AND on send failure. ~20 LOC, prevents message loss | Error propagation only (loses messages on transient failures) |
| is_from_me metadata | NanoClaw passes bot messages through marked is_from_me: true for conversation tracking | Filter out bot messages entirely (loses conversation context) |
| Feature gate slack | Zero cost when disabled, follows existing pattern | Always-on (bloats binary), runtime config only (still compiles dep) |
| No api_base_url override | slack-morphism hardcodes SLACK_API_URI_STR | Forking slack-morphism (maintenance burden), monkey-patching (fragile) |
| Testing via digital twin at HTTP level | Complies with "no mocks" policy, reusable by SLB | Mocks (prohibited), real Slack only (no CI), forking slack-morphism |
Eliminated Options (Essentialism)
| Option Rejected | Why Rejected | Risk of Including | |-----------------|--------------|-------------------| | Block Kit message builder | Plain text + mrkdwn sufficient for MVP | Doubles API surface, 3x code for formatting | | OAuth installation flow | Single workspace, manual token setup is fine | Weeks of work for a feature used once | | Thread reply support | Flat messages work for personal assistant | Complicates OutboundMessage, needs thread_ts tracking | | Slack interactive components | Bot only sends/receives text | Massive API surface (buttons, modals, views) | | Custom hyper connector for URL rewrite | Complex, fragile, depends on slack-morphism internals | Breaks on library updates, hard to debug | | Filter out all bot messages | Simpler but loses conversation context | Agent cannot track what it said previously |
Simplicity Check
What if this could be easy?
Copy telegram.rs (150 LOC). Replace teloxide::Bot with SlackClient. Replace
teloxide::Dispatcher with Socket Mode listener. Replace markdown_to_telegram_html with
markdown_to_slack_mrkdwn. Add ~50 LOC for bot detection + mention stripping + name cache +
dedup. Total: ~200 LOC new code + ~80 LOC tests.
Senior Engineer Test: A senior engineer would look at the Telegram adapter, look at slack-morphism docs, and produce the same design. Nothing clever here.
Nothing Speculative Checklist:
- [x] No features the user did not request
- [x] No abstractions "in case we need them later"
- [x] No flexibility "just in case"
- [x] No error handling for scenarios that cannot occur
- [x] No premature optimization
File Changes
New Files
| File | Purpose |
|------|---------|
| crates/terraphim_tinyclaw/src/channels/slack.rs | Slack channel adapter (~200 LOC) |
Modified Files
| File | Changes |
|------|---------|
| crates/terraphim_tinyclaw/Cargo.toml | Add slack-morphism dep + slack feature |
| crates/terraphim_tinyclaw/src/channels/mod.rs | Add #[cfg(feature = "slack")] pub mod slack; |
| crates/terraphim_tinyclaw/src/config.rs | Add SlackConfig struct + field in ChannelsConfig |
| crates/terraphim_tinyclaw/src/channel.rs | Add slack branch in build_channels_from_config() |
| crates/terraphim_tinyclaw/src/format.rs | Add markdown_to_slack_mrkdwn() function |
Deleted Files
None.
API Design
Public Types
// In config.rs
/// Slack channel configuration.
// In channels/slack.rs
/// Slack channel adapter using slack-morphism Socket Mode.
Public Functions
// In config.rs
// In channels/slack.rs
// Channel trait implementation (start, stop, send, is_running, is_allowed)// In format.rs
/// Convert markdown to Slack mrkdwn format.
///
/// Slack mrkdwn differences from standard markdown:
/// - Bold: *text* (not **text**)
/// - Italic: _text_ (same)
/// - Strikethrough: ~text~ (not ~~text~~)
/// - Code: `code` (same)
/// - Code block: ```code``` (same)
/// - Links: <url|text> (not [text](url))
/// - No nested formatting
;Internal Functions (in slack.rs)
/// Strip bot @mention from incoming message text.
/// Converts "<@U_BOT_ID> hello" to "hello".
;
/// Check if a message event is from the bot itself.
;
/// Resolve a Slack user ID to a display name, using cache.
async ;
/// Check if an event has already been processed (dedup).
;Outgoing Queue (in slack.rs)
/// Queued outbound message for retry on reconnect or send failure.
/// The outgoing queue lives inside SlackChannel:
/// In send(): on chat.postMessage failure, push to queue instead of returning error.
/// On reconnect (in start()): flush queue before processing new events.
/// NanoClaw pattern: ~20 LOC total.is_from_me Metadata (in event handler)
// In the Socket Mode event handler, bot messages are NOT filtered out.
// Instead, they are forwarded to the bus with metadata:
let mut inbound = new;
if is_own_message
// The agent/session layer can use these flags for conversation history tracking.
// Non-allowed senders are still rejected by the allowlist check.Test Strategy
Unit Tests (public CI, no network, no tokens)
| Test | Location | Purpose |
|------|----------|---------|
| test_slack_config_validate_valid | config.rs | Valid config passes |
| test_slack_config_validate_empty_bot_token | config.rs | Rejects empty bot_token |
| test_slack_config_validate_empty_app_token | config.rs | Rejects empty app_token |
| test_slack_config_validate_empty_allow_from | config.rs | Rejects empty allow_from |
| test_slack_config_is_allowed | config.rs | Allowlist matching |
| test_slack_config_is_allowed_wildcard | config.rs | Wildcard "*" allows all |
| test_slack_channel_name | slack.rs | Returns "slack" |
| test_strip_bot_mention | slack.rs | Strips <@UBOTID> from text |
| test_strip_bot_mention_no_match | slack.rs | Leaves text unchanged if no mention |
| test_strip_bot_mention_multiple | slack.rs | Handles multiple mentions |
| test_is_own_message_by_user_id | slack.rs | Detects own message by user match |
| test_is_own_message_by_bot_id | slack.rs | Detects own message by bot_id field |
| test_is_own_message_other_user | slack.rs | Allows other users' messages |
| test_is_duplicate_event | slack.rs | First occurrence passes, second blocked |
| test_outgoing_queue_on_disconnect | slack.rs | Messages queued when not connected |
| test_is_from_me_metadata | slack.rs | Bot messages carry is_from_me=true metadata |
| test_markdown_to_slack_mrkdwn_bold | format.rs | **bold** -> *bold* |
| test_markdown_to_slack_mrkdwn_strikethrough | format.rs | ~~text~~ -> ~text~ |
| test_markdown_to_slack_mrkdwn_link | format.rs | [text](url) -> <url\|text> |
| test_markdown_to_slack_mrkdwn_code | format.rs | Backticks pass through |
| test_chunk_message_slack | format.rs | Chunks at 4000 chars |
Integration Tests (public repo, #[ignore], env-var-gated)
| Test | Location | Purpose |
|------|----------|---------|
| test_slack_channel_lifecycle | tests/slack_integration.rs | start -> verify running -> stop |
| test_slack_send_message | tests/slack_integration.rs | Send message via channel, verify delivery |
These tests run against either twin-slack (private CI) or real Slack (manual validation).
Gated by SLACK_BOT_TOKEN + SLACK_APP_TOKEN env vars.
E2E Tests (private repo, twin-slack, no tokens needed)
Located in zestic-ai/digital-twins -- not part of this plan. Separate work item.
Implementation Steps
Step 1: SlackConfig and Validation
Files: crates/terraphim_tinyclaw/src/config.rs
Description: Add SlackConfig struct, wire into ChannelsConfig, add validation
Tests: 6 unit tests for config validation and allowlist
Estimated: 1 hour
// Add to config.rs
// Add to ChannelsConfig:
pub slack: ,
// Add to ChannelsConfig::validate():
if let Some = self.slack Step 2: Slack mrkdwn Formatting
Files: crates/terraphim_tinyclaw/src/format.rs
Description: Add markdown_to_slack_mrkdwn() function
Tests: 5 unit tests for formatting conversions
Dependencies: None
Estimated: 1 hour
// Add to format.rs
Step 3: Cargo.toml and Module Registration
Files: Cargo.toml, channels/mod.rs, channel.rs
Description: Add slack-morphism dependency, feature flag, module declaration, factory branch
Tests: Compile check
Dependencies: Step 1
Estimated: 30 min
# Add to Cargo.toml [dependencies]
slack-morphism = { version = "2.18", optional = true, features = ["hyper_tokio"] }
# Add to [features]
slack = ["dep:slack-morphism"]
# Update default (do NOT include slack in default -- opt-in)
# default = ["telegram", "discord"] (unchanged)// channels/mod.rs
// channel.rs -- add to build_channels_from_config()
Step 4: SlackChannel Implementation
Files: crates/terraphim_tinyclaw/src/channels/slack.rs
Description: Core adapter -- Socket Mode listener, event handler with is_from_me tracking, send with outgoing queue + retry-on-failure
Tests: 10 unit tests for helper functions (strip_mention, is_own_message, dedup, outgoing_queue, is_from_me)
Dependencies: Steps 1, 2, 3
Estimated: 5-6 hours
//! Slack channel adapter using slack-morphism Socket Mode.
use crate;
use crateChannel;
use crateSlackConfig;
use async_trait;
use ;
use Arc;
use ;
use ;
// --- Helper functions (testable without network) ---
/// Flush the outgoing queue. Called after Socket Mode reconnect.
async Step 5: Integration Test Scaffold
Files: crates/terraphim_tinyclaw/tests/slack_integration.rs
Description: Ignored integration tests that run against twin-slack or real Slack
Tests: 2 integration tests (lifecycle, send message)
Dependencies: Step 4
Estimated: 1 hour
//! Slack integration tests.
//! Run with: SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... cargo test -p terraphim_tinyclaw --features slack -- --ignored
Rollback Plan
Feature-gated -- if issues discovered:
- Remove
slackfrom default features (already not in default) - Users simply don't enable
--features slack - No data migration, no state changes, no external side effects
Dependencies
New Dependencies
| Crate | Version | Justification |
|-------|---------|---------------|
| slack-morphism | 2.18 | Socket Mode + events + chat API. Only mature Rust Slack lib. |
Note: slack-morphism brings transitive deps (hyper, hyper-rustls, tokio-tungstenite).
These are already present in the workspace via other crates. Feature-gated so only
included when --features slack is used.
Dependency Updates
None.
Performance Considerations
Expected Performance
| Metric | Target | Measurement | |--------|--------|-------------| | Socket Mode connect | < 3s | Manual timing | | Message processing | < 100ms | Log timestamps | | User name cache hit | < 1us | HashMap lookup | | Event dedup check | < 1us | HashSet lookup |
Memory
- User name cache: ~100 bytes per user, bounded by workspace size (typically < 100 users)
- Event dedup set: ~50 bytes per event ID, grows over session lifetime. For a long-running bot processing 1000 messages/day, this is ~50KB/day. Acceptable for MVP. Phase 2 can add LRU eviction if needed.
twin-slack (Private Repo) -- Companion Work
This section documents what needs to happen in zestic-ai/digital-twins (private).
It is NOT part of the terraphim-ai PR.
twin-slack Crate (Phase 1: HTTP only)
New files in zestic-ai/digital-twins:
| File | Purpose |
|------|---------|
| crates/twin-slack/Cargo.toml | Crate manifest |
| crates/twin-slack/src/lib.rs | Router + state |
| crates/twin-slack/src/auth.rs | auth.test endpoint |
| crates/twin-slack/src/chat.rs | chat.postMessage endpoint |
| crates/twin-slack/src/users.rs | users.info endpoint |
| specs/slack/API_SPECIFICATION.md | Slack API subset spec |
Endpoints:
POST /auth.test-- returns{ ok: true, user_id: "U_BOT", team_id: "T_TEST" }POST /chat.postMessage-- stores message, returns{ ok: true, ts: "..." }POST /users.info?user=U123-- returns configurable user profile
twin-server mount:
Testing Strategy in Private CI
- Private CI workflow in
zestic-ai/digital-twinsspawnstwin-serverwith--features slack - Adds
terraphim_tinyclawas a git dependency (from terraphim-ai main branch) - Runs
cargo test -p terraphim_tinyclaw --features slack -- --ignoredwithSLACK_BOT_TOKEN=xoxb-test SLACK_APP_TOKEN=xapp-testpointing to twin-slack - This validates the integration without real Slack tokens
Note: Phase 1 twin-slack has HTTP endpoints only. The Socket Mode WebSocket simulation is Phase 2 work -- for MVP, the integration tests verify API calls (auth.test, chat.postMessage) but not the full Socket Mode event flow. The event handling logic is covered by unit tests.
Open Items
| Item | Status | Owner | |------|--------|-------| | Verify slack-morphism 2.18 compiles with workspace tokio version | Pending | Implementation | | Create Slack App and provision tokens | Pending | Alex | | Create GitHub issue for Slack adapter (#terraphim-ai) | Pending | Implementation | | Create twin-slack work item (digital-twins repo) | Pending | Separate |
Approval
- [ ] File changes reviewed
- [ ] Public APIs reviewed
- [ ] Test strategy approved (unit + integration + twin)
- [ ] twin-slack boundary approved (no private leakage)
- [ ] Feature flag approach approved (opt-in, not default)
- [ ] Human approval received