The Discord ingress is built on the BaseChannelAdapter pattern — a shared base class that handles agent-server dispatch, while the DiscordAdapter subclass implements Discord-specific I/O.
flowchart LR
A["Discord\nGateway"] -->|WebSocket| B["DiscordAdapter\n(discord.js)"]
B -->|"dispatch()"| C["BaseChannelAdapter\n(shared)"]
C -->|HTTP POST| D["agent-server\n127.0.0.1:8788"]
D -->|runs agent| E["OpenHands\nruntime"]
E -->|turn result +\noutbound msgs| D
D -->|poll + claim| C
C -->|"sendReply()"| B
B -->|reply| A
Two files, clear separation:
| File | Lines | Responsibility |
|---|---|---|
index.ts | ~40 | Entry point — imports adapter, starts via registry, handles shutdown |
adapter.ts | ~300 | DiscordAdapter — all Discord-specific logic |
channelAdapter.ts (shared) | ~300 | BaseChannelAdapter + ChannelRegistry — agent-server dispatch loop |
submitConversationMessage()monitorTurn()start() / stop() with resource cleanupconnect() — create discord.js Client, login, listen for eventsdisconnect() — destroy clientsendReply(ctx, text) — split & send via message.reply()sendTyping(ctx) — channel.sendTyping()shouldRespond(), isAllowed(), extractPrompt(), buildConversationId()
sequenceDiagram
participant U as Discord User
participant D as Discord API
participant DA as DiscordAdapter
participant BA as BaseChannelAdapter
participant S as agent-server
U->>D: message or @mention
D->>DA: MessageCreate (WebSocket)
DA->>DA: shouldRespond() + isAllowed()
DA->>DA: extractPrompt() + buildConversationId()
DA->>BA: dispatch(msg, replyCtx)
BA->>BA: sendTyping() + start interval
BA->>S: submitConversationMessage()
loop poll until terminal
BA->>S: getTurnStatus()
BA->>S: claimOutboundMessages()
BA->>DA: sendReply() for each outbound
DA->>D: message.reply()
end
BA->>S: getTurnResult()
BA->>DA: sendReply(finalReply)
DA->>D: message.reply()
D->>U: sees reply
1. Ignore bot messages 2. Respond if bot is @mentioned 3. Respond if trigger pattern matches (default: @smolpaws) 4. Respond to all DMs 5. Otherwise: ignore
1. If ALLOWED_USERS set → user must be in the list 2. DMs always pass (after user check) 3. If ALLOWED_GUILDS set → guild must be in the list 4. If ALLOWED_CHANNELS set → channel must be in the list
| Context | Format | Example |
|---|---|---|
| DM | discord-dm-{author_id} | discord-dm-456789012 |
| Thread | discord-thread-{channel_id} | discord-thread-987654321 |
| Channel | discord-channel-{channel_id} | discord-channel-111222333 |
The adapter self-registers with the ChannelRegistry on import. The entry point is minimal:
import { channelRegistry } from '../../../src/shared/channelAdapter.js';
import './adapter.js'; // triggers registration
await channelRegistry.startAdapter('discord', {
runnerUrl: RUNNER_URL,
runnerToken: RUNNER_TOKEN,
logger,
});
The registry provides:
register(name, factory) — register an adapter factorystartAdapter(name, config) — create, start, and track an instancestopAdapter(name) / stopAll() — graceful shutdownAll ingress apps share the same adapter base and turn client:
flowchart TB
subgraph "Ingress Apps"
D["Discord\nDiscordAdapter"]
S["Slack\n(next)"]
G["GitHub\n(next)"]
end
subgraph "Shared Layer"
BA["BaseChannelAdapter\nchannelAdapter.ts"]
T["turnClient.ts"]
R["ChannelRegistry"]
end
subgraph "Backend"
A["agent-server"]
end
D --> BA
S -.-> BA
G -.-> BA
BA --> T
R --> BA
T --> A
Discord is the first adapter on the pattern. Slack and GitHub will follow the same shape — subclass BaseChannelAdapter, implement platform I/O, register with the registry.
| Variable | Required | Default | Purpose |
|---|---|---|---|
DISCORD_BOT_TOKEN | yes | — | Discord bot token |
SMOLPAWS_RUNNER_URL | no | http://127.0.0.1:8788 | Agent server base URL |
SMOLPAWS_RUNNER_TOKEN | no | — | Auth token for agent server |
DISCORD_TRIGGER | no | @smolpaws | Text trigger pattern |
DISCORD_ALLOWED_GUILDS | no | — | Comma-separated guild IDs |
DISCORD_ALLOWED_CHANNELS | no | — | Comma-separated channel IDs |
DISCORD_ALLOWED_USERS | no | — | Comma-separated usernames |
This adapter pattern is the first step toward a plugin system for channel extensibility. The goal: adding a new messaging platform should be “subclass BaseChannelAdapter, implement 3 methods, register with the registry” — not “copy an existing app and rewire everything.”
See smolpaws#115 for the full extensibility roadmap.