* Add channel integrations with Discord adapter and atomic session resume (#24) Bidirectional channel adapter framework connecting external messaging platforms to turnstone workstreams via Redis MQ. Discord ships as the first adapter; the protocol supports future Slack/Teams integrations. Channel framework: - ChannelAdapter protocol and ChannelRouter for channel↔workstream mapping - AsyncRedisBroker with single dispatch loop and per-channel ordered workers - channel_routes table (migration 003) for persistent route storage - 9 new StorageBackend methods (4 channel_user + 5 channel_route CRUD) - Unified turnstone-channel gateway entry point, loads adapters by config - Message chunking, approval formatting, plan review formatting Discord adapter: - discord.py v2.4+ bot with thread-per-@mention model - Slash commands: /link (modal), /unlink, /ask, /status, /close - Persistent button views for tool approval and plan review - Streaming responses via edit-in-place (1.5s interval) - Stale route detection and atomic session resume via resume_session field - SessionResumedEvent confirmation back to channel - Auto-approve support (blanket + per-tool list) Atomic session resume: - resume_session field on CreateWorkstreamMessage for single-request resume - Server resumes session during POST /v1/api/workstreams/new atomically - Bridge emits SessionResumedEvent to per-workstream channel - WorkstreamCreatedEvent extended with resumed/session_id/message_count - Server UI dashboardResumeSession simplified to single request - Pruned sessions fall back gracefully to fresh start Service auth: - Bridge and console auto-mint service JWTs from TURNSTONE_JWT_SECRET - Bridge: approve scope (1 week). Console collector: read. Proxy: write. Console admin: - Channels tab with per-user view, force-link modal, unlink - 3 admin API endpoints for channel user management - Styled confirm modals replacing browser confirm() dialogs Bug fixes: - AsyncRedisBroker: replaced per-channel listener tasks with single dispatch loop + per-channel queue workers (fixes message stealing race) - Bridge: approval/plan review dedup guard prevents SSE reconnect duplicates - Bridge: _active_sends tracked for initial messages (fixes missing TurnCompleteEvent and unfinalized streaming messages) - Bridge: HTTP calls moved outside lock scope in approval handlers - Bridge: _handle_send cleans up _active_sends on HTTP/server errors - Formatter: reads server SSE format (func_name/preview) with fallback Docs, SDK, tests: - docs/channels.md setup guide, architecture diagram 16 - Updated api-reference.md, architecture.md, console.md, docker.md - Python SDK: resume_session param on create_workstream (async + sync) - TypeScript SDK: updated CreateWorkstreamRequest/Response interfaces - OpenAPI schema: resume_session request, resumed/message_count response - 91 new tests (19 storage, 15 broker, 22 protocol, 6 routing, 18 discord, 12 resume flow) — 1120 total passing * Fix CI lint/typecheck failures and address Copilot review feedback (#24) Lint: fix import ordering, remove unused imports, use contextlib.suppress. Mypy: explicit postgresql dialect import, add discord module overrides for optional-dependency CI environments. Copilot: fix double-escaping in admin confirm modals, return resolved session_id from server resume response, fix channel_routes diagram schema, use atomic setdefault for routing locks, add post-insert race guard in admin channel create, support SSE format in auto-approve check, update identity linking note in architecture diagram. * Fix remaining mypy call-arg errors for discord.py optional dependency Add type: ignore[call-arg] on Modal(title=) and Cog(name=) class definitions that fail when discord.py is not installed in CI.
10 KiB
Channel Integrations
The turnstone-channel gateway connects external messaging platforms to
turnstone workstreams via Redis MQ. Each platform adapter translates
platform-native events (messages, button clicks, slash commands) into
turnstone MQ messages, and renders workstream output back into the
platform's UI.
Discord ships as the first adapter. The adapter protocol is designed for future Slack and Teams integrations.
Architecture
Discord Gateway
|
v
turnstone-channel (Discord adapter)
|
v
Redis MQ
|
v
turnstone-bridge ──> turnstone-server
Key components:
- ChannelAdapter protocol (
turnstone/channels/_protocol.py) — generic interface for any messaging platform. Definesstart(),stop(),send(),edit_message(),send_approval_request(),send_plan_review(), andcreate_thread(). - ChannelRouter (
turnstone/channels/_routing.py) — maps channel/thread IDs to turnstone workstream IDs. Handles workstream creation via MQ, stale route detection, and user identity resolution. - AsyncRedisBroker (
turnstone/mq/async_broker.py) — async Redis client compatible with discord.py's event loop. Used by the router for pub/sub and queue operations. - channel_users table — maps
(channel_type, channel_user_id)to a turnstoneuser_id. Messages from unlinked users are silently dropped. - channel_routes table — persistent channel-to-workstream mappings. Survives bot restarts. Stale routes (evicted workstreams) are detected and refreshed on the next message.
Discord Setup
1. Create a Discord Application
- Go to https://discord.com/developers/applications
- Click New Application and give it a name
- Navigate to the Bot tab and click Reset Token to generate a bot token. Copy it immediately — it is shown only once.
- On the same Bot tab, scroll down to Privileged Gateway Intents and enable MESSAGE CONTENT INTENT
- Navigate to OAuth2 > URL Generator
- Under Scopes, check
botandapplications.commands - Under Bot Permissions, check:
- View Channels
- Send Messages
- Send Messages in Threads
- Create Public Threads
- Read Message History
- Add Reactions
- Embed Links
- Copy the generated URL, open it in a browser, and add the bot to your Discord server
2. Configure Turnstone
Environment variables (recommended for Docker):
TURNSTONE_DISCORD_TOKEN=your-bot-token-here
TURNSTONE_DISCORD_GUILD=123456789 # optional, restrict to one guild
CLI flags (bare-metal):
turnstone-channel \
--discord-token "your-bot-token" \
--discord-guild 123456789 \
--redis-host localhost \
--redis-port 6379
Docker Compose (production profile):
# In .env file:
TURNSTONE_DISCORD_TOKEN=your-bot-token
TURNSTONE_DISCORD_GUILD=123456789
Then start the stack:
docker compose --profile production up
The channel service starts automatically when
TURNSTONE_DISCORD_TOKEN is set.
3. Link User Accounts
Discord users must link their account to a turnstone user before they can interact with the bot. Unlinked users' messages are silently ignored.
- The user must have a turnstone API token — created via the admin panel
or
turnstone-admin create-token - In Discord, the user runs
/link. A modal appears prompting for the API token (the token is never visible in Discord audit logs because it is submitted via modal, not as a slash command argument). - The token is validated against the database. If valid, a
channel_usersmapping is created. - The user can now @mention the bot or use slash commands.
An admin can also force-link or unlink users via the console admin panel (Admin > Channels tab).
Usage
Conversations
- @mention the bot in any allowed channel to start a new conversation. The bot creates a Discord thread from the message and a turnstone workstream behind it.
- All subsequent messages in the thread are routed to the same workstream.
- The bot streams responses via message edits, updated approximately every 1.5 seconds.
- If the workstream is evicted for capacity, the next message in the
thread auto-creates a new workstream and atomically resumes the
previous session via the
resume_sessionfield onCreateWorkstreamMessage. The server resumes the session during workstream creation (same HTTP request), and the bridge emits aSessionResumedEventback to the channel. The thread receives a "Session resumed: {name} ({count} messages restored)" confirmation.
Slash Commands
| Command | Description |
|---|---|
/link |
Link Discord account to turnstone (opens modal for API token) |
/unlink |
Unlink Discord account |
/ask <message> |
Create a new thread and workstream with an initial message |
/status |
Show workstream info for the current thread (ephemeral) |
/close |
Close the workstream, delete the route, and archive the thread |
Tool Approvals
When manual approval is enabled (the default), tool calls are displayed as an orange embed with:
- Tool name and argument preview
- Approve (green), Reject (red), Always Approve (gray) buttons
- Only linked users can interact with approval buttons
- The approval decision is forwarded through MQ to the bridge, which relays it to the server
Buttons use static custom_id values so they survive bot restarts.
Correlation data (ws_id, correlation_id) is stored in the embed footer.
Auto-approval: When auto_approve is true (via --auto-approve), or when
all tools in the request match the auto_approve_tools list in the adapter
config, the bot auto-responds with approval and posts a
"Tool auto-approved." notice to the thread instead of showing buttons. The
auto_approve_tools list is set via the ChannelConfig.auto_approve_tools
field (useful for allowing specific tools like bash or read_file while
still requiring manual approval for others).
Plan Reviews
Plan review requests are displayed as a blue embed with:
- Approve Plan (green) button — approves the plan with empty feedback
- Request Changes (gray) button — opens a modal for feedback text (up to 2000 characters)
- Feedback is forwarded through MQ as a
PlanFeedbackMessage
Configuration Reference
| CLI Flag | Env Var | Default | Description |
|---|---|---|---|
--discord-token |
TURNSTONE_DISCORD_TOKEN |
— | Bot token (required to enable Discord) |
--discord-guild |
— | 0 (all guilds) |
Restrict to a single Discord guild |
--discord-channels |
— | empty (all) | Comma-separated channel IDs to allow |
--redis-host |
REDIS_HOST |
localhost |
Redis host |
--redis-port |
— | 6379 |
Redis port |
--redis-password |
REDIS_PASSWORD |
— | Redis password |
--redis-db |
— | 0 |
Redis DB number |
--model |
— | server default | Default model for new workstreams |
--auto-approve |
— | false |
Auto-approve ALL tool calls (skips approval buttons entirely) |
--log-level |
TURNSTONE_LOG_LEVEL |
INFO |
Log level |
--log-format |
TURNSTONE_LOG_FORMAT |
auto |
Log format (auto/json/text) |
User Identity
- The
channel_userstable maps(channel_type, channel_user_id)to a turnstoneuser_id - Self-service linking via the
/linkslash command (modal input, not visible in Discord audit logs) - Admin can force-link or unlink via the console admin panel (Admin > Channels tab). Unlinking uses a styled confirmation modal.
- Unlinked users' messages are silently dropped
- A user can be linked across multiple platforms (e.g. Discord + Slack)
See Security: Database Schema for the
channel_users table definition.
Workstream Lifecycle
- Creation — @mention or
/askcreates a Discord thread and a turnstone workstream. TheChannelRouterpersists the mapping in thechannel_routestable. - Active — messages are routed bidirectionally. The bot streams responses via message edits (updated every ~1.5 seconds).
- Eviction — the server evicts an idle workstream for capacity. The route is preserved and the thread stays open.
- Reactivation — the next message in the thread detects the stale
route (no MQ owner), looks up the old session via
get_session_id_by_ws(), and creates a new workstream withresume_sessionset atomically on theCreateWorkstreamMessage. The server resumes the session during creation (no separate command needed). The bridge emits aSessionResumedEventto the channel, and the thread displays "Session resumed: {name} ({count} messages restored)". If the old session was pruned, the workstream starts fresh with no error. - Close —
/closecommand closes the workstream via MQ, deletes the route, unsubscribes from events, and archives the Discord thread.
Adding New Adapters
The ChannelAdapter protocol defines the interface any platform adapter
must implement:
class ChannelAdapter(Protocol):
channel_type: str
async def start(self) -> None: ...
async def stop(self) -> None: ...
async def send(self, channel_id: str, content: str) -> str: ...
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None: ...
async def send_approval_request(self, channel_id: str, ws_id: str, correlation_id: str, items: list[dict]) -> None: ...
async def send_plan_review(self, channel_id: str, ws_id: str, correlation_id: str, content: str) -> None: ...
async def create_thread(self, parent_channel_id: str, name: str, message_id: str = "") -> str: ...
To add a new platform:
- Create
turnstone/channels/<platform>/package - Implement the
ChannelAdapterprotocol - Add a
--<platform>-tokenflag and detection logic inturnstone/channels/cli.py - Add the optional dependency in
pyproject.toml(e.g.turnstone[slack])
See turnstone/channels/discord/ as a reference implementation.