mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
110d44b07e
`man` and `math` duplicated capabilities already reachable through `bash`; `plan_agent` is better expressed as a `task_agent` running a planning skill, and carried a large amount of special-case machinery (plan-review gate, refinement loop, per-kind model routing). Removing all three shrinks the tool surface and cuts per-call token cost. Also removed, as dead-once-the-tools-are-gone: - the `math` sandbox executor (`turnstone.core.sandbox`) and its `[sandbox]` extra; the eval analyst now runs bash-only - the read-only `AGENT_TOOLS` sub-agent tool set and the `agent` tool-metadata key (`task_agent`/`TASK_AGENT_TOOLS` retained) - the plan-review protocol end to end: the `on_plan_review` UI hook, `resolve_plan`, `POST /v1/api/plan` + `POST /v1/api/route/plan`, the `plan_review`/`plan_resolved` SSE events, and their Python SDK / TypeScript SDK / OpenAPI / frontend / Discord+Slack bindings - the `model.plan_alias` / `model.plan_effort` settings and the registry `plan_model` / `plan_effort` routing fields TOOLS 31->28, TASK_AGENT_TOOLS 13->11; COORDINATOR_TOOLS unchanged. BREAKING CHANGE: removes the `man`, `math`, `plan_agent` tools, the plan-review SSE/HTTP/SDK surface, and the plan_* model-routing settings from the experimental 1.6 line.
439 lines
17 KiB
Markdown
439 lines
17 KiB
Markdown
# Channel Integrations
|
|
|
|
The `turnstone-channel` gateway connects external messaging platforms to
|
|
turnstone workstreams via direct HTTP to the server (single-node) or the
|
|
console routing proxy (multi-node). Each platform adapter translates
|
|
platform-native events (messages, button clicks, slash commands) into
|
|
turnstone API calls, and renders workstream output back into the
|
|
platform's UI.
|
|
|
|
Discord and Slack adapters ship today. The adapter protocol is designed
|
|
so new platforms can be added with only a new package under
|
|
`turnstone/channels/<platform>/`.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Discord Gateway Slack (Socket Mode WebSocket)
|
|
\ /
|
|
v v
|
|
turnstone-channel (one or more adapters)
|
|
|
|
|
v
|
|
turnstone-server (direct HTTP)
|
|
or
|
|
turnstone-console (routing proxy, multi-node)
|
|
```
|
|
|
|
A single `turnstone-channel` process can run multiple adapters
|
|
simultaneously (e.g. Discord + Slack) — pass the tokens for each
|
|
platform you want to enable.
|
|
|
|
Key components:
|
|
|
|
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
|
|
interface for any messaging platform. Defines `start()`, `stop()`,
|
|
`send()`, and `send_notification()`.
|
|
- **ChannelRouter** (`turnstone/channels/_routing.py`) — maps
|
|
channel/thread IDs to turnstone workstream IDs. Handles workstream
|
|
creation via HTTP, stale route detection, and user identity resolution.
|
|
- **channel_users table** — maps `(channel_type, channel_user_id)` to a
|
|
turnstone `user_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
|
|
|
|
1. Go to https://discord.com/developers/applications
|
|
2. Click **New Application** and give it a name
|
|
3. Navigate to the **Bot** tab and click **Reset Token** to generate a
|
|
bot token. Copy it immediately — it is shown only once.
|
|
4. On the same **Bot** tab, scroll down to **Privileged Gateway Intents**
|
|
and enable **MESSAGE CONTENT INTENT**
|
|
5. Navigate to **OAuth2 > URL Generator**
|
|
6. Under **Scopes**, check `bot` and `applications.commands`
|
|
7. Under **Bot Permissions**, check:
|
|
- View Channels
|
|
- Send Messages
|
|
- Send Messages in Threads
|
|
- Create Public Threads
|
|
- Read Message History
|
|
- Add Reactions
|
|
- Embed Links
|
|
8. 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):
|
|
|
|
```bash
|
|
TURNSTONE_DISCORD_TOKEN=your-bot-token-here
|
|
TURNSTONE_DISCORD_GUILD=123456789 # optional, restrict to one guild
|
|
```
|
|
|
|
**CLI flags** (bare-metal):
|
|
|
|
```bash
|
|
turnstone-channel \
|
|
--discord-token "your-bot-token" \
|
|
--discord-guild 123456789 \
|
|
--server-url http://localhost:8080
|
|
```
|
|
|
|
**Docker Compose** (production profile):
|
|
|
|
```bash
|
|
# In .env file:
|
|
TURNSTONE_DISCORD_TOKEN=your-bot-token
|
|
TURNSTONE_DISCORD_GUILD=123456789
|
|
```
|
|
|
|
Then start the stack:
|
|
|
|
```bash
|
|
docker compose up
|
|
```
|
|
|
|
The `channel` gateway runs by default; the Discord adapter activates once
|
|
`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.
|
|
|
|
1. The user must have a turnstone API token — created via the admin panel
|
|
or `turnstone-admin create-token`
|
|
2. 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).
|
|
3. The token is validated against the database. If valid, a
|
|
`channel_users` mapping is created.
|
|
4. 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).
|
|
|
|
---
|
|
|
|
## Slack Setup
|
|
|
|
Slack uses **Socket Mode**, so no public URL or API Gateway is required — Slack
|
|
connects outbound to the bot via a WebSocket. Install with:
|
|
|
|
```bash
|
|
pip install 'turnstone[slack]'
|
|
```
|
|
|
|
### 1. Create a Slack App
|
|
|
|
1. Go to https://api.slack.com/apps and click **Create New App**
|
|
2. Under **Settings > Socket Mode**, enable Socket Mode. This generates an
|
|
**App-Level Token** (prefix `xapp-`) — copy it.
|
|
3. Under **OAuth & Permissions**, add these **Bot Token Scopes**:
|
|
`chat:write`, `chat:write.public`, `channels:history`, `im:history`,
|
|
`groups:history`, `mpim:history`, `reactions:write`, `commands`
|
|
4. Under **Event Subscriptions** (Socket Mode delivers events), subscribe
|
|
to bot events: `message.channels`, `message.im`, `message.groups`
|
|
5. Under **Slash Commands**, create a command (default `/turnstone`)
|
|
6. Install the app to your workspace to generate the **Bot User OAuth
|
|
Token** (prefix `xoxb-`).
|
|
|
|
### 2. Configure Turnstone
|
|
|
|
**Environment variables** (recommended for Docker):
|
|
|
|
```bash
|
|
TURNSTONE_SLACK_TOKEN=xoxb-... # Bot User OAuth Token
|
|
TURNSTONE_SLACK_APP_TOKEN=xapp-... # App-Level Token (Socket Mode)
|
|
TURNSTONE_SLACK_CHANNELS= # optional, comma-separated channel IDs
|
|
TURNSTONE_SLACK_SLASH_COMMAND=/turnstone
|
|
```
|
|
|
|
**CLI flags** (bare-metal):
|
|
|
|
```bash
|
|
turnstone-channel \
|
|
--slack-token "xoxb-..." \
|
|
--slack-app-token "xapp-..." \
|
|
--slack-slash-command /turnstone \
|
|
--server-url http://localhost:8080
|
|
```
|
|
|
|
The Slack and Discord adapters can be enabled together — pass tokens for
|
|
both and the gateway hosts both adapters in one process.
|
|
|
|
### 3. Usage
|
|
|
|
- **DM the bot**: messages sent directly to the bot create a workstream
|
|
scoped to that DM; the slash command is not required.
|
|
- **Slash command**: `/turnstone <message>` in any channel the bot can
|
|
see starts a per-user channel session.
|
|
- Tool approvals render as Slack **Block Kit** buttons; only the user
|
|
who owns the workstream can approve/reject.
|
|
- Notifications and reply routing work identically to Discord.
|
|
- Session recovery: persisted channel routes are re-subscribed when the
|
|
bot restarts, so existing Slack conversations keep flowing.
|
|
|
|
---
|
|
|
|
## 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 workstream via the `resume_ws` field on
|
|
`CreateWorkstreamMessage`. The server resumes the workstream during
|
|
creation (same HTTP request), and the server emits a
|
|
`WorkstreamResumedEvent` back to the channel. The thread receives a
|
|
*"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 to the server via HTTP
|
|
|
|
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).
|
|
|
|
---
|
|
|
|
## Configuration Reference
|
|
|
|
| CLI Flag | Env Var | Default | Description |
|
|
|----------|---------|---------|-------------|
|
|
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Discord bot token (required to enable Discord) |
|
|
| `--discord-guild` | — | `0` (all guilds) | Restrict to a single Discord guild |
|
|
| `--discord-channels` | — | empty (all) | Comma-separated Discord channel IDs to allow |
|
|
| `--slack-token` | `TURNSTONE_SLACK_TOKEN` | — | Slack Bot User OAuth token (`xoxb-…`, required to enable Slack) |
|
|
| `--slack-app-token` | `TURNSTONE_SLACK_APP_TOKEN` | — | Slack App-Level token (`xapp-…`, required with `--slack-token`) |
|
|
| `--slack-channels` | `TURNSTONE_SLACK_CHANNELS` | empty (all) | Comma-separated Slack channel IDs to allow |
|
|
| `--slack-slash-command` | `TURNSTONE_SLACK_SLASH_COMMAND` | `/turnstone` | Slash command name registered in the Slack app |
|
|
| `--server-url` | `TURNSTONE_SERVER_URL` | `http://localhost:8080` | Server URL (single-node) |
|
|
| `--console-url` | `TURNSTONE_CONSOLE_URL` | — | Console URL (multi-node routing proxy) |
|
|
| `--model` | — | server default | Default model for new workstreams |
|
|
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
|
|
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
|
|
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
|
|
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
|
|
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
|
|
|
At least one of `--discord-token` or `--slack-token` must be supplied.
|
|
Passing both starts both adapters in the same process.
|
|
|
|
---
|
|
|
|
## User Identity
|
|
|
|
- The `channel_users` table maps `(channel_type, channel_user_id)` to a
|
|
turnstone `user_id`
|
|
- Self-service linking via the `/link` slash 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](security.md#database-schema) for the
|
|
`channel_users` table definition.
|
|
|
|
---
|
|
|
|
## Workstream Lifecycle
|
|
|
|
1. **Creation** — @mention or `/ask` creates a Discord thread and a
|
|
turnstone workstream. The `ChannelRouter` persists the mapping in the
|
|
`channel_routes` table.
|
|
2. **Active** — messages are routed bidirectionally. The bot streams
|
|
responses via message edits (updated every ~1.5 seconds).
|
|
3. **Eviction** — the server evicts an idle workstream for capacity. The
|
|
route is preserved and the thread stays open.
|
|
4. **Reactivation** — the next message in the thread detects the stale
|
|
route and creates a new workstream with the old `ws_id`
|
|
as `resume_ws` on the creation request. The server resumes
|
|
the workstream during creation (no separate command or reverse lookup
|
|
needed). The channel receives a `WorkstreamResumedEvent`, and
|
|
the thread displays *"Resumed: {name} ({count} messages restored)"*.
|
|
If the old workstream was pruned, a fresh one starts with no error.
|
|
5. **Close** — `/close` command closes the workstream via HTTP, deletes the
|
|
route, unsubscribes from events, and archives the Discord thread.
|
|
|
|
---
|
|
|
|
## Notifications
|
|
|
|
> See also: [Notification Flow diagram](diagrams/png/17-notify-flow.png)
|
|
|
|
The `notify` tool allows the LLM to proactively send notifications to
|
|
users or channels on external platforms. This is useful for alerting
|
|
people about task completion, errors, or important updates without
|
|
waiting for them to check in.
|
|
|
|
### Targeting
|
|
|
|
Two modes:
|
|
|
|
- **Username** — provide a turnstone `username`. The gateway resolves
|
|
it via the `channel_users` table and sends to every linked platform
|
|
the user has (e.g. Discord + Slack).
|
|
- **Direct** — provide `channel_type` + `channel_id` to target a
|
|
specific platform channel or user DM.
|
|
|
|
### Delivery Flow
|
|
|
|
Notifications use direct HTTP for low latency. The server calls the channel
|
|
gateway directly over HTTP:
|
|
|
|
1. The LLM calls the `notify` tool with a message and target
|
|
2. `_exec_notify()` queries the `services` table for healthy channel
|
|
gateways (heartbeat within the last 120 seconds)
|
|
3. The server mints a service JWT (`aud: turnstone-channel`) via
|
|
`ServiceTokenManager` and POSTs to the first healthy gateway. The
|
|
payload includes the originating `ws_id` for reply routing.
|
|
4. The gateway validates the JWT, resolves the target, and calls
|
|
`adapter.send_notification()` which sends the message and tracks
|
|
the outgoing message ID for reply routing
|
|
5. On failure, the server tries the next gateway. If all fail, it
|
|
retries up to 2 more times (delays: 1s, 3s), re-querying the
|
|
service registry on each attempt
|
|
|
|
### Bidirectional Replies
|
|
|
|
Notifications support multi-turn DM conversations. When a user replies
|
|
to a notification DM:
|
|
|
|
1. The bot looks up the originating `ws_id` from the tracked message ID
|
|
(`_notify_ws_map`)
|
|
2. Verifies the replying user matches the original notification
|
|
recipient (defence in depth — Discord DMs are already private)
|
|
3. Routes the reply to the workstream via `router.send_message()`
|
|
4. Registers the DM channel for response forwarding
|
|
(`_notify_reply_channels`)
|
|
5. When the workstream responds (`TurnCompleteEvent`), the response is
|
|
forwarded to the DM
|
|
6. The response message is itself tracked, so the user can reply again
|
|
for another turn
|
|
|
|
This enables scenarios like an oncall engineer responding to a CI/CD
|
|
failure notification from their phone before opening a laptop.
|
|
|
|
**Limits:**
|
|
|
|
- Tracking map capped at 100 entries (FIFO eviction of oldest)
|
|
- Entries cleaned up on workstream close/unsubscribe
|
|
- Replying to an expired notification sends
|
|
*"This notification is no longer active."*
|
|
- DM reply content capped at 4096 characters
|
|
|
|
### Service Registry
|
|
|
|
The channel gateway registers itself in the `services` database table
|
|
on startup and sends a heartbeat every 30 seconds. On shutdown it
|
|
deregisters. Services are considered stale after 120 seconds (4 missed
|
|
heartbeats) and are excluded from `list_services()` queries.
|
|
|
|
The `services` table schema:
|
|
|
|
| Column | Description |
|
|
|--------|-------------|
|
|
| `service_type` | Service category (e.g. `"channel"`) |
|
|
| `service_id` | Unique instance ID (`channel-<hostname>-<random>`) |
|
|
| `url` | HTTP base URL for the service |
|
|
| `last_heartbeat` | ISO 8601 timestamp of last heartbeat |
|
|
| `created` | ISO 8601 timestamp of initial registration |
|
|
|
|
### Security
|
|
|
|
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
|
|
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
|
|
server can mint JWTs with `aud: turnstone-channel` automatically.
|
|
If the secret is not set, the gateway fails closed and rejects all
|
|
requests with 401. Server JWTs (`aud: turnstone-server`) are
|
|
rejected.
|
|
- **Rate limit** — maximum 5 notifications per turn. The counter only
|
|
increments on successful delivery, so failures don't consume the
|
|
budget.
|
|
- **SSRF protection** — only `http://` and `https://` service URLs
|
|
are allowed. Other schemes are silently skipped.
|
|
- **Mention sanitization** — `discord.utils.escape_mentions()` is
|
|
applied before sending, preventing `@everyone` / `@here` abuse.
|
|
- **Error redaction** — generic error messages are returned to the
|
|
LLM. Internal details (service IDs, URLs, exception messages) are
|
|
logged server-side only.
|
|
|
|
---
|
|
|
|
## Adding New Adapters
|
|
|
|
The `ChannelAdapter` protocol defines the interface any platform adapter
|
|
must implement:
|
|
|
|
```python
|
|
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 send_notification(self, channel_id: str, content: str, ws_id: str) -> str: ...
|
|
```
|
|
|
|
`send_notification()` is like `send()` but associates the outgoing
|
|
message with a `ws_id` so that user replies can be routed back to the
|
|
originating workstream. Adapters must track the mapping from outgoing
|
|
message ID to `(ws_id, target_user_id)` and handle DM replies.
|
|
|
|
Platform-specific concerns — approval prompts, message edits, thread
|
|
creation — live inside the adapter implementation and are not part of
|
|
the protocol surface. Each adapter drives those via its
|
|
own `_on_ws_event` dispatcher using SDK-native APIs.
|
|
|
|
To add a new platform:
|
|
|
|
1. Create `turnstone/channels/<platform>/` package
|
|
2. Implement the `ChannelAdapter` protocol
|
|
3. Add a `--<platform>-token` flag and detection logic in
|
|
`turnstone/channels/cli.py`
|
|
4. Add the optional dependency in `pyproject.toml` (e.g.
|
|
`turnstone[slack]`)
|
|
|
|
See `turnstone/channels/discord/` as a reference implementation.
|