GNL 0.17: early and moving fast — APIs can change between releasesJoin us

GNL
Docs menu
Core · Free@gnldev/channels

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.

slack.ts
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.

telegram.ts
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 approvers allows); 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#

fnslackChannel

(config, { signingSecret, botToken, agent, workspaces, studioUrl?, approvers?, worker?, workerOptions? }) → { handle(req), worker(), close() }.

fntelegramChannel

(config, { botToken, secretToken, agent, chats, studioUrl?, worker?, workerOptions? }) → { handle(req), worker(), close() }.

fnverifySlackSignature

(rawBody, headers, signingSecret, nowMs) → boolean — Slack's v0 signature, constant-time, five-minute bound.

Needs a long-lived process
The agent runs in a queue worker after the webhook has answered. On a platform that freezes a function once its response is sent, the run waits for the next request. Run the worker in a server or container (worker: false and channel.worker().start() to run it elsewhere).