Skip to main content
Tesslate OpenSail

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

PlatformInbound modeAdapter file
TelegramWebhook or long-pollorchestrator/app/services/channels/telegram.py
SlackEvents API webhook or Socket Modeorchestrator/app/services/channels/slack.py
DiscordGateway WebSocket via discord.py, or interactions webhookorchestrator/app/services/channels/discord_bot.py
WhatsAppMeta Cloud API webhookorchestrator/app/services/channels/whatsapp.py
Signalsignal-cli REST SSEorchestrator/app/services/channels/signal.py
CLIAuthenticated WebSocketorchestrator/app/services/channels/cli_websocket.py

Prerequisites

1

Run OpenSail

Bring up the backend in Docker, Kubernetes, or desktop mode. See the Self-Hosting quickstart if you need the full install.
2

Set CHANNEL_ENCRYPTION_KEY

Generate a base64 Fernet key:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
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.
3

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.
4

Enable the gateway

GATEWAY_ENABLED=true is the default. The scheduler polls agent_schedules every GATEWAY_TICK_INTERVAL seconds (default 60).
5

Public HTTPS for webhook platforms

Telegram webhooks, Slack Events API, Discord interactions, and WhatsApp all require a public HTTPS endpoint. Socket-mode flavors (Slack Socket Mode, Discord gateway, Telegram long-poll, Signal SSE) work behind NAT with no inbound port.

Architecture

Platform (Slack, Telegram, ...)
          |
          v   webhook POST  or  outbound WS / poll
  /api/channels/webhook/{type}/{config_id}  <---  HTTP ingress
          |                                         |
          v                                         v
  ChannelConfig (Fernet-encrypted creds)     GatewayRunner (runner.py)
          |                                         |
          |    inbound MessageEvent                 |
          +---------------------------------------->+
                                                    |
                                                    v
                                  TaskQueue (ARQ cloud / asyncio desktop)
                                                    |
                                                    v
                                       Agent worker runs agent loop
                                                    |
                                                    v
                                   Redis XADD -> gateway_delivery_stream
                                                    |
                                                    v
                                  GatewayRunner._delivery_consumer
                                                    |
                                                    v
                                    adapter.send_gateway_response(chat_id, text)
                                                    |
                                                    v
                                      Reply lands in the original chat
Key invariants:
  • One adapter per ChannelConfig row. Adapters are keyed by config_id inside GatewayRunner.adapters.
  • Per-session ordering: the runner derives a session_key from platform:chat_type:chat_id[:thread_id] so concurrent messages in the same chat are serialized.
  • Response routing uses Redis XREADGROUP on gateway_delivery_stream, so only the pod holding the session’s adapter delivers the reply.
  • A PlatformIdentity row links a platform account (telegram:12345) to an OpenSail User, gating who the agent will respond to.

Identity pairing

Every platform user must be linked to an OpenSail user before the agent talks back.
1

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.
2

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.
3

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.
4

Identity goes live

The backend assigns user_id, sets is_verified=true, and stamps paired_at. All future inbound messages from that platform account run under that OpenSail user.
Manage identities at 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.
1

Create a Slack app

Go to https://api.slack.com/apps and install it to your workspace.
2

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.
3

Copy credentials

Copy the bot token (xoxb-...) and the signing secret.
4

Pick inbound mode

  • Socket Mode (recommended): enable Socket Mode, generate an app-level token (xapp-...), and subscribe to message.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.
5

Create the channel in OpenSail

Settings > Channels > New > Slack. Paste bot_token, signing_secret, and optional app_token. Save.
6

Invite the bot

Invite the bot into any channel you want it to hear from. DMs work automatically.

Creating schedules

Schedules let an agent run on a clock and push the result anywhere. The UI lives at app/src/pages/settings/SchedulesSettings.tsx. The API is /api/schedules (see orchestrator/app/routers/schedules.py).
POST /api/schedules
{
  "project_id": "...",
  "agent_id": "...",
  "name": "Friday summary",
  "schedule": "every Friday at 9am",
  "timezone": "America/New_York",
  "repeat": null,
  "prompt_template": "Summarize this week and post the highlights.",
  "deliver": "telegram:123456789"
}
Parser and runner files:
  • 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 every GATEWAY_TICK_INTERVAL, picks due schedules, enqueues the agent task, increments runs_completed, and updates next_run_at.

Delivery targets

TargetMeaning
originReplies 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 the send_message tool. Full reference: tesslate-agent DOCS.
send_message(
  channel="discord",
  target="<chat_id>",
  text="Hello from the agent",
)
  • send_message is a dangerous tool and is subject to plan-mode and approval gating (see DANGEROUS_TOOLS in the agent docs).
  • The backend resolves the right ChannelConfig for 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_URL and the tool will post through that webhook even if no Discord ChannelConfig exists.

Credential security

  • Credentials are serialized to JSON and Fernet-encrypted before insert. See encrypt_credentials in orchestrator/app/services/channels/registry.py.
  • CHANNEL_ENCRYPTION_KEY must 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.
  • GET endpoints on /api/channels never return raw credentials. The UI masks values via _mask_credentials in orchestrator/app/routers/channels.py.
  • Webhook URLs include a per-config webhook_secret (see generate_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

Set GATEWAY_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> or docker compose logs -f gateway. Look for [GATEWAY], [TG-GW], [SLACK-GW], [DISCORD-GW], [SIGNAL-GW] prefixes.
  • GET /api/gateway/status returns shard count, adapter count, heartbeat, last sync timestamp.
  • ChannelMessage rows log inbound and outbound messages with direction, status, platform_message_id, and the associated task_id. Query by channel_config_id.
  • POST /api/gateway/reload forces a diff-based adapter resync.

Common failure modes

SymptomLikely cause
Slack webhook returns 401Wrong signing_secret, or clock skew greater than 300 seconds. The gateway rejects any timestamp outside that window.
Discord signature failuresWrong public_key, or the body was rewritten by a reverse proxy. Verify Ed25519 over raw bytes.
Telegram sends messages but never receivessetWebhook was called but the URL is unreachable. Switch to long-poll mode by leaving the webhook unset.
WhatsApp webhook never firesverify_token mismatch, or the app is not subscribed to messages events.
Bot ignores a messageNo verified PlatformIdentity for that sender. The gateway should have replied with an 8-character pairing code.
Schedule fires but no deliveryRedis is not configured, so the delivery consumer is a no-op. Set REDIS_URL.

Production notes

  • GATEWAY_SHARD and GATEWAY_NUM_SHARDS shard ChannelConfig rows across gateway replicas. Each config is pinned to a shard via channel_configs.gateway_shard.
  • Use a Recreate update strategy with replicas=1 per shard so there is only ever one active adapter per config. The file lock at GATEWAY_LOCK_DIR is defense in depth for Docker Compose.
  • GATEWAY_SESSION_IDLE_MINUTES controls how long an inactive session holds its place in the runner’s per-session ordering (default 1440).
  • GATEWAY_TICK_INTERVAL is 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_stream Redis Stream is capped at GATEWAY_DELIVERY_MAXLEN entries (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.