The Slack ingress is a long-running local Node.js process. It connects to Slack via Socket Mode WebSocket (@slack/bolt), receives events, fetches thread context, and dispatches to the shared SmolPaws agent server over HTTP.
flowchart LR
A["Slack\nSocket Mode"] <-->|WebSocket| B["apps/slack\n(@slack/bolt)"]
B -->|HTTP POST| C["agent-server\n127.0.0.1:8788"]
C -->|runs agent| D["OpenHands\nruntime"]
D -->|turn result +\noutbound msgs| C
C -->|poll + claim| B
B -->|chat.postMessage\nwith thread_ts| A
sequenceDiagram
participant U as Slack User
participant S as Slack API
participant I as apps/slack
participant A as agent-server
participant R as Agent Runtime
U->>S: DM or @smolpaws mention
S->>I: event via Socket Mode
I->>I: dedup + checkAccess()
I->>S: reactions.add(eyes)
I->>I: stripBotMention()
alt threaded message
I->>S: conversations.replies()
S-->>I: thread history
I->>I: formatThreadContext()
end
I->>I: buildConversationId()
I->>A: submitConversationMessage()
A->>R: create/resume conversation
loop poll until terminal
I->>A: getTurnStatus()
I->>A: claimTurnOutboundMessages()
I-->>S: deliver outbound messages
end
I->>A: getTurnResult()
I->>S: chat.postMessage (thread_ts)
S->>U: sees reply
The bot decides whether to respond through layered checks:
1. Ignore bot messages and self-mentions 2. app_mention events → always process 3. message.im events → always process (DMs) 4. Channel thread replies → only if thread was previously mentioned 5. Everything else → ignore
1. Non-DMs: check team and channel allowlists (if configured) 2. If user is in ALLOWED_USER_IDS → allowed 3. If no user allowlist configured → allowed (open) 4. Otherwise → guest (rate-limited to 5 conversations)
When the bot is mentioned in a thread, it fetches prior messages via conversations.replies and prepends them as context to the prompt. This lets the agent understand the conversation that preceded the mention.
[Thread context] <@U1>: What is OpenHands? <@U2>: An AI agent platform for software development smolpaws: It's built on top of... [Current message] Can you explain the runtime architecture?
smolpaws, others as <@UID>Stable IDs derived from Slack's own identifiers:
| Context | Pattern | Example |
|---|---|---|
| DM | slack-im-{team}-{channel} | slack-im-T06P-D08X |
| Thread | slack-thread-{team}-{channel}-{root_ts} | slack-thread-T06P-C123-1717200000.000100 |
thread_ts)ts as rootOnce smolpaws has been mentioned in a thread, subsequent replies in that thread are processed without requiring another @mention. The MentionedThreadTracker keeps an in-memory set of tracked threads (capped at 1000, LRU-evicted).
flowchart TB
A["@smolpaws help me"] -->|app_mention| B["handleSlackEvent"]
B --> C["mentionedThreads.track(thread_ts)"]
C --> D["dispatch to agent"]
E["follow-up reply\n(no @mention)"] -->|message event| F["mentionedThreads.isTracked?"]
F -->|yes| B
F -->|no| G["ignore"]
The Slack app uses the shared turnClient.ts — the same client used by Discord and the GitHub Cloudflare Worker.
flowchart TB
subgraph "apps/slack"
A["slackHandler.ts"] --> B["agentServerClient.ts"]
end
subgraph "src/shared/turnClient.ts"
C1["submitConversationMessage()"]
C2["monitorTurn()"]
C3["getTurnStatus()"]
C4["claimTurnOutboundMessages()"]
C5["getTurnResult()"]
C2 --> C3 & C4 & C5
end
B --> C1
B --> C2
Key details:
{channel}:{ts} to prevent double-processing.| Feature | Implementation |
|---|---|
| Deduplication | In-memory MessageDeduplicator with 60s TTL, auto-prunes at 200 entries |
| Eyes reaction | 👀 added to the triggering message immediately on acceptance (before dispatch) |
| Guest rate limiting | Non-allowlisted users get 5 conversations total, persisted to disk |
| Bot loop prevention | Ignores bot messages, self-mentions, and message subtypes |
| Message splitting | Long replies split at 5900 chars, preferring newlines then spaces |
| Graceful errors | Dispatch failures post a friendly error message, don't crash the process |
The Hermes agent uses the same Socket Mode approach but at a very different scale:
| Aspect | Hermes (SlackAdapter) | SmolPaws (apps/slack) |
|---|---|---|
| Language | Python (slack-bolt) | TypeScript (@slack/bolt) |
| Scale | ~3000-line adapter, 20+ platforms in one gateway | ~600 lines source, standalone ingress app |
| Routing | Single GatewayRunner multiplexes all platforms | HTTP dispatch to shared agent-server |
| Thread context | Session-presence auto-reply, thread memory | conversations.replies fetch + context formatting |
| Thread follow-ups | Bot-mentioned-thread tracking | MentionedThreadTracker with LRU eviction |
| Dedup | MessageDeduplicator | MessageDeduplicator with TTL + auto-prune |
| Slash commands | All gateway commands as native slashes | Not yet |
| Rich content | Block Kit, files, audio, approval buttons | Plain text |
SmolPaws' design is intentionally simpler. The complexity lives in the agent server, not the ingress adapter. Each ingress is a thin adapter — receive platform events, build a conversation ID, dispatch, deliver results back.
| Scope | Purpose |
|---|---|
app_mentions:read | Receive @mention events in channels |
chat:write | Post replies |
im:history | Read DM messages |
reactions:write | Add 👀 acknowledgment reactions |
| Event | Purpose |
|---|---|
app_mention | Channel/thread mentions |
message.im | DM messages |
| Variable | Required | Default | Purpose |
|---|---|---|---|
SLACK_BOT_TOKEN | yes | — | Bot token (xoxb-...) |
SLACK_APP_TOKEN | yes | — | App-level token for Socket Mode (xapp-...) |
SMOLPAWS_RUNNER_URL | no | http://127.0.0.1:8788 | Agent server base URL |
SMOLPAWS_RUNNER_TOKEN | no | — | Auth token for agent server |
SLACK_ALLOWED_TEAM_IDS | no | — | Comma-separated workspace IDs |
SLACK_ALLOWED_CHANNEL_IDS | no | — | Comma-separated channel IDs |
SLACK_ALLOWED_USER_IDS | no | — | Comma-separated user IDs |
apps/slack/
├── package.json
├── tsconfig.json
├── AGENTS.md
└── src/
├── index.ts # Bolt app, Socket Mode, event handlers
├── config.ts # env parsing, allowlists
├── slackContext.ts # conversation IDs, access, thread context, dedup
├── slackHandler.ts # handleSlackEvent() — testable core logic
├── agentServerClient.ts # turn API wrapper
└── __tests__/
├── agentServerClient.test.ts
├── slackContext.test.ts
└── slackHandler.test.ts
@slack/bolt Socket Mode (not HTTP webhooks)slackHandler.ts with injectable dependencies for testabilitySlack, Discord, and GitHub all share the same turn client:
flowchart TB
subgraph "Ingress Apps"
D["Discord\napps/discord"]
G["GitHub\napps/github"]
S["Slack\napps/slack"]
end
subgraph "Shared"
T["turnClient.ts\nsrc/shared/"]
end
subgraph "Backend"
A["agent-server\napps/agent-server"]
end
D --> T
G --> T
S --> T
T --> A
Each ingress is a thin adapter: receive platform events → build conversation ID → dispatch via turnClient → deliver results back. Agent logic, conversation state, and turn management all live in the agent server.