
Overview
OpenSail agents do not need to live inside the app. The Gateway is a persistent process that maintains live connections to messaging platforms so users can talk to an agent wherever they already work. A message from Telegram, Slack, Discord, WhatsApp, Signal, or an authenticated CLI WebSocket flows through the same pipeline as a browser chat, runs inside a sandboxed agent worker, and replies back to the originating thread, DM, or channel.Six built-in adapters
Telegram, Slack, Discord, WhatsApp, Signal, and CLI WebSocket, each with inbound and outbound support.
Cron schedules
Agents run on their own with natural-language or cron expressions and deliver to any channel.
Identity pairing
8-character pairing codes link platform accounts to OpenSail users via
PlatformIdentity.Platform adapters
| Platform | Inbound mode | Adapter file |
|---|---|---|
| Telegram | Webhook or long-poll | orchestrator/app/services/channels/telegram.py |
| Slack | Events API webhook or Socket Mode | orchestrator/app/services/channels/slack.py |
| Discord | Gateway WebSocket via discord.py, or interactions webhook | orchestrator/app/services/channels/discord_bot.py |
| Meta Cloud API webhook | orchestrator/app/services/channels/whatsapp.py | |
| Signal | signal-cli REST SSE | orchestrator/app/services/channels/signal.py |
| CLI | Authenticated WebSocket | orchestrator/app/services/channels/cli_websocket.py |
Prerequisites
Run OpenSail
Bring up the backend in Docker, Kubernetes, or desktop mode. See the Self-Hosting quickstart if you need the full install.
Set CHANNEL_ENCRYPTION_KEY
Generate a base64 Fernet key:Set it as
CHANNEL_ENCRYPTION_KEY. OpenSail falls back to DEPLOYMENT_ENCRYPTION_KEY and then SECRET_KEY (see get_channel_encryption_key in orchestrator/app/config.py), but an explicit channel key is strongly recommended.Configure Redis
Set
REDIS_URL if you want scheduled delivery, hot reload, or multi-pod routing. Without Redis the gateway still handles inbound messages in-process on desktop, but delivery to non-browser channels is disabled.Enable the gateway
GATEWAY_ENABLED=true is the default. The scheduler polls agent_schedules every GATEWAY_TICK_INTERVAL seconds (default 60).Architecture
- One adapter per
ChannelConfigrow. Adapters are keyed byconfig_idinsideGatewayRunner.adapters. - Per-session ordering: the runner derives a
session_keyfromplatform:chat_type:chat_id[:thread_id]so concurrent messages in the same chat are serialized. - Response routing uses Redis
XREADGROUPongateway_delivery_stream, so only the pod holding the session’s adapter delivers the reply. - A
PlatformIdentityrow links a platform account (telegram:12345) to an OpenSailUser, gating who the agent will respond to.
Identity pairing
Every platform user must be linked to an OpenSail user before the agent talks back.User messages the bot
The user sends a message from the platform. The gateway sees no verified
PlatformIdentity for the (platform, platform_user_id) pair.Gateway replies with a pairing code
The gateway generates an 8-character code, stores it on an unverified
PlatformIdentity row with pairing_expires_at = now + GATEWAY_PAIRING_CODE_TTL (default 1 hour), and sends it back through the same channel.User verifies in OpenSail
The user opens OpenSail, navigates to Settings, and submits the code to
POST /api/gateway/pair/verify with { "platform": "...", "pairing_code": "..." }. See orchestrator/app/routers/gateway.py.GET /api/gateway/identities (list) and DELETE /api/gateway/identities/{id} (unlink). Rate limits flow through GATEWAY_PAIRING_MAX_PENDING and GATEWAY_PAIRING_RATE_LIMIT_MINUTES.
Per-platform setup
Configure all platforms from Settings > Channels in the OpenSail UI (app/src/pages/settings/ChannelsSettings.tsx) or directly through POST /api/channels. Credentials are Fernet-encrypted in channel_configs.credentials.
- Slack
- Telegram
- Discord
- WhatsApp
- Signal
- CLI WebSocket
Add bot scopes
Under OAuth and Permissions, add:
chat:write, im:history, channels:history, groups:history, mpim:history, users:read. Add files:read if you want attachments.Pick inbound mode
- Socket Mode (recommended): enable Socket Mode, generate an app-level token (
xapp-...), and subscribe tomessage.channels,message.im,message.groups,message.mpim. No public URL required. - Events API webhook: paste
https://<your-domain>/api/channels/webhook/slack/<config_id>as the Request URL after creating the config.
Create the channel in OpenSail
Settings > Channels > New > Slack. Paste
bot_token, signing_secret, and optional app_token. Save.Creating schedules
Schedules let an agent run on a clock and push the result anywhere. The UI lives atapp/src/pages/settings/SchedulesSettings.tsx. The API is /api/schedules (see orchestrator/app/routers/schedules.py).
- Natural language parser:
orchestrator/app/services/gateway/schedule_parser.py - Any valid 5-field cron passes through unchanged.
CronScheduler(orchestrator/app/services/gateway/scheduler.py) ticks everyGATEWAY_TICK_INTERVAL, picks due schedules, enqueues the agent task, incrementsruns_completed, and updatesnext_run_at.
Delivery targets
| Target | Meaning |
|---|---|
origin | Replies to whatever channel or chat created the schedule, using origin_platform, origin_chat_id, origin_config_id |
telegram:<chat_id> | Specific Telegram chat |
discord:<channel_id> | Specific Discord channel |
slack:<channel_id> | Specific Slack channel |
whatsapp:<phone> | Specific WhatsApp recipient |
signal:<recipient> | Specific Signal recipient |
Per-user limits are enforced by
GATEWAY_MAX_SCHEDULES_PER_USER (default 50).Agents sending messages
Agents can push messages into channels on their own using thesend_message tool. Full reference: tesslate-agent DOCS.
send_messageis a dangerous tool and is subject to plan-mode and approval gating (seeDANGEROUS_TOOLSin the agent docs).- The backend resolves the right
ChannelConfigfor the current user and calls its adapter. Targets for DMs are the platform user ID; for channels, the channel or chat ID. - For quick Discord notifications without a full bot, set
AGENT_DISCORD_WEBHOOK_URLand the tool will post through that webhook even if no DiscordChannelConfigexists.
Credential security
- Credentials are serialized to JSON and Fernet-encrypted before insert. See
encrypt_credentialsinorchestrator/app/services/channels/registry.py. CHANNEL_ENCRYPTION_KEYmust be a base64 Fernet key. If a raw string is provided, the registry derives a Fernet key through SHA-256 to avoid a cold-start crash, but this path is a fallback, not a recommendation.GETendpoints on/api/channelsnever return raw credentials. The UI masks values via_mask_credentialsinorchestrator/app/routers/channels.py.- Webhook URLs include a per-config
webhook_secret(seegenerate_webhook_secret) on top of platform signature verification: HMAC-SHA256 for Slack and WhatsApp, Ed25519 for Discord, secret-token header for Telegram. - Key rotation: generate a new key, re-encrypt existing rows, set the new key. Channel credentials are small, so a migration script that reads decrypted values and writes them back with the new key works cleanly.
Voice transcription
SetGATEWAY_VOICE_TRANSCRIPTION=true (default) and GATEWAY_VOICE_MODEL (default whisper-1) to transcribe inbound voice messages before they reach the agent. The gateway downloads audio via the platform’s file API (for example Telegram getFile, Slack files.info), sends it to the configured LiteLLM transcription model, and forwards the resulting text as a normal TEXT MessageEvent. Cached media lives under GATEWAY_MEDIA_CACHE_DIR and ages out after GATEWAY_MEDIA_CACHE_MAX_AGE_HOURS.
Testing and debugging
Useful places to look when something is off:kubectl logs deploy/tesslate-gateway --context=<name>ordocker compose logs -f gateway. Look for[GATEWAY],[TG-GW],[SLACK-GW],[DISCORD-GW],[SIGNAL-GW]prefixes.GET /api/gateway/statusreturns shard count, adapter count, heartbeat, last sync timestamp.ChannelMessagerows log inbound and outbound messages withdirection,status,platform_message_id, and the associatedtask_id. Query bychannel_config_id.POST /api/gateway/reloadforces a diff-based adapter resync.
Common failure modes
| Symptom | Likely cause |
|---|---|
| Slack webhook returns 401 | Wrong signing_secret, or clock skew greater than 300 seconds. The gateway rejects any timestamp outside that window. |
| Discord signature failures | Wrong public_key, or the body was rewritten by a reverse proxy. Verify Ed25519 over raw bytes. |
| Telegram sends messages but never receives | setWebhook was called but the URL is unreachable. Switch to long-poll mode by leaving the webhook unset. |
| WhatsApp webhook never fires | verify_token mismatch, or the app is not subscribed to messages events. |
| Bot ignores a message | No verified PlatformIdentity for that sender. The gateway should have replied with an 8-character pairing code. |
| Schedule fires but no delivery | Redis is not configured, so the delivery consumer is a no-op. Set REDIS_URL. |
Production notes
GATEWAY_SHARDandGATEWAY_NUM_SHARDSshardChannelConfigrows across gateway replicas. Each config is pinned to a shard viachannel_configs.gateway_shard.- Use a
Recreateupdate strategy withreplicas=1per shard so there is only ever one active adapter per config. The file lock atGATEWAY_LOCK_DIRis defense in depth for Docker Compose. GATEWAY_SESSION_IDLE_MINUTEScontrols how long an inactive session holds its place in the runner’s per-session ordering (default 1440).GATEWAY_TICK_INTERVALis the cron scheduler granularity. Sixty seconds is a good default. Dropping below 30 raises load without gaining much resolution.- Heartbeat keys
tesslate:gateway:active:{config_id}expire every 120 seconds. Use them in Grafana for liveness. - The
gateway_delivery_streamRedis Stream is capped atGATEWAY_DELIVERY_MAXLENentries (default 10000) so crashes never lose pending deliveries forever.
Where to next
Deployment targets
Ship your agent’s containers to 22 external providers in the same canvas flow.
Publishing apps
Package an agent workspace as an installable Tesslate App with its own billing.
Agent tool reference
Full surface for
send_message, approval policy, plan mode, and tool gating.