Docs menu
Channels: Slack & Telegram
An agent in Slack or Telegram: a message run once, the reply posted once, approval buttons only the person who asked can answer — no Redis, the store is the queue.
What it's for / when to use it#
Put an agent where your team or your customers already talk. @gnldev/channels takes a platform's signed webhook, turns each message into a durable queue job in your store, runs the agent, and posts the answer back in the thread.
Chat platforms redeliver: Slack sends an event again when an answer is slow, Telegram until it gets a 2xx. A bot that runs the agent on every delivery answers twice and, worse, runs its tools twice. Here a message is named by the platform's id for it, so a redelivery is the same job and the same run — and the reply is a recorded side effect like any tool call.
Slack#
slackChannel(config, opts) returns handle(request) — mount it at the URL your Slack app's Event Subscriptions and Interactivity point to — and starts a queue worker. workspaces lists the workspaces it answers and the organization each runs in; a workspace not listed is acknowledged and never run.
import { slackChannel } from '@gnldev/channels';
import { SqliteStorage } from '@gnldev/durable/sqlite';
const slack = slackChannel({ storage: new SqliteStorage('runs.db'), agents }, {
signingSecret: process.env.SLACK_SIGNING_SECRET!,
botToken: process.env.SLACK_BOT_TOKEN!,
agent: 'support',
workspaces: { T0123ABCD: { orgId: 'acme' } },
studioUrl: 'https://studio.acme.example',
});
// Event Subscriptions and Interactivity both point here:
export const handleSlack = (req: Request) => slack.handle(req);Bot events app_mention and message.im; scopes app_mentions:read, chat:write, im:history, and channels:history / groups:history where the bot is mentioned (the reply is looked up there after a lost connection). approvers lets someone other than the asker answer an approval.
Telegram#
telegramChannel(config, opts) is the same for a bot: point setWebhook at your mount with secret_token = secretToken. chats decides which chats are served, and in which organization — a bot is reachable by anyone who finds it.
import { telegramChannel } from '@gnldev/channels';
const telegram = telegramChannel({ storage, agents }, {
botToken: process.env.TELEGRAM_BOT_TOKEN!,
secretToken: process.env.TELEGRAM_WEBHOOK_SECRET!, // the secret_token you give setWebhook
agent: 'support',
chats: (chat) => (chat.id === -1001234567890 ? { orgId: 'acme' } : undefined),
});
export const handleTelegram = (req: Request) => telegram.handle(req);One difference, stated rather than hidden: a bot cannot read a chat's history, so a reply whose connection was cut cannot be looked up. On Telegram the reply is sent at most once — left in doubt, never resent — where on Slack it is found or sent.
What it promises#
- Only the platform is heard. Slack: a request without its signature for the body, or older than five minutes, is 401. Telegram: an update without the webhook's secret token is 401.
- A message is run once. A queue job named by the platform's id for the message (Slack's event id; Telegram's chat and message id); the run is named the same, so a job retried after a crash replays the run — its tools do not run twice. Measured: six deliveries of one event, one model call, one post.
- The reply is posted once on Slack. The post carries its idempotency key in Slack's message metadata; a post cut off mid-flight is settled by reading the thread — found: posted; not found: posted now. Measured both ways: one post each.
- Each person's conversation is their own. The GNL thread is the platform's thread and the person; in a thread two people share, neither run reads the other's messages.
- Approvals in the chat. A run that stops for an approval posts Approve / Deny for each call. Only the person who asked may answer (on Slack, also whoever
approversallows); a Slack button's value is signed, so a changed one decides nothing; one decision per call. On Telegram the asker is read from the message the buttons reply to. - The platform gets its answer at once (Slack wants one in 3 s); the agent runs in the queue worker afterwards — in a process that stays up, not a function that freezes after the response.
API reference#
slackChannel(config, { signingSecret, botToken, agent, workspaces, studioUrl?, approvers?, worker?, workerOptions? }) → { handle(req), worker(), close() }.
telegramChannel(config, { botToken, secretToken, agent, chats, studioUrl?, worker?, workerOptions? }) → { handle(req), worker(), close() }.
verifySlackSignature(rawBody, headers, signingSecret, nowMs) → boolean — Slack's v0 signature, constant-time, five-minute bound.
worker: false and channel.worker().start() to run it elsewhere).