# Gateway

URL: https://cogitator.app/docs/channels/gateway

> Route messages from messaging platforms to AI agents with sessions, middleware, and streaming.

## Overview

The `Gateway` is the central class of `@cogitator-ai/channels`. It connects messaging channels (Telegram, Discord, Slack, WhatsApp, WebChat) to your AI agents, handling sessions, middleware, streaming, and message formatting.

```bash
pnpm add @cogitator-ai/channels
```

## Quick Start

```typescript
import { Cogitator, Agent } from '@cogitator-ai/core';
import { Gateway, telegramChannel } from '@cogitator-ai/channels';

const agent = new Agent({
  name: 'assistant',
  model: 'anthropic/claude-sonnet-5-5',
  instructions: 'You are a helpful personal assistant.',
});

const gateway = new Gateway({
  agent,
  cogitator: new Cogitator({
    llm: {
      providers: { anthropic: { apiKey: process.env.ANTHROPIC_API_KEY } },
    },
  }),
  channels: [telegramChannel({ token: process.env.TG_TOKEN! })],
});

await gateway.start();
```

## GatewayConfig

```typescript
interface GatewayConfig {
  agent: Agent | ((user: ChannelUser) => Agent | Promise<Agent>);
  channels: Channel[];
  cogitator: Cogitator;
  memory?: MemoryAdapter;
  sessionManager?: SessionManager; // defaults to SessionManager(memory)
  middleware?: GatewayMiddleware[];
  hooks?: HookRegistry;
  mediaProcessor?: MediaProcessor;
  runTimeout?: number; // ms, defaults to the agent's timeout

  session?: {
    threadKey?: (msg: ChannelMessage) => string;
    compaction?: CompactionConfig;
  };

  stream?: StreamConfig;
  reactions?: StatusReactionConfig;
  debounce?: DebounceConfig;
  envelope?: EnvelopeConfig;
  queueMode?: 'parallel' | 'sequential' | 'interrupt' | 'collect';
  approvals?: {
    format?: (approvals: readonly ToolApprovalRequest[], words: ApprovalReplyWords) => string;
    approveWords?: readonly string[]; // default: approve, yes, да, одобряю
    denyWords?: readonly string[]; // default: deny, no, нет, отклоняю
    notAllowedMessage?: string;
  };

  onError?: (error: Error, msg: ChannelMessage) => void;
}
```

`GatewayConfig.owner` is deprecated and never read: configure owners on the middleware that uses them, `ownerCommands({ ownerIds })` and `dmPolicy({ ownerIds })` (see [Middleware](/docs/channels/middleware)).

Without `memory` (and without `sessionManager`) the Gateway keeps no history: every run starts from an empty thread. `onError` receives failures of runs, middleware and compaction; the message is dropped silently when it is not set.

### Per-user Agents

Pass a factory function to create different agents per user:

```typescript
const gateway = new Gateway({
  agent: (user) => {
    if (user.id === 'admin') {
      return adminAgent;
    }
    return defaultAgent;
  },
  // ...
});
```

## Message Flow

```
User sends message
  → Channel converts to ChannelMessage
  → Debouncer (if enabled): buffer rapid messages, merge into one
  → MessageQueue (if queueMode is not 'parallel'): sequential/interrupt/collect per thread
  → Middleware pipeline (dm-policy → owner-commands → rate-limit → ...)
  → message:received hook
  → Agent resolved (factory called per message), session lookup/create (emits session:created)
  → Compaction (if the thread holds ≥ threshold messages)
  → Status reaction 👀 → 🤔, typing indicator every 4s
  → Approve/deny reply on a paused thread? cogitator.resume(agent, threadId, { defaultDecision })
    otherwise: envelope (if enabled), media processing, cogitator.run(agent, { input, threadId, ... })
  → 🔥 while tools run, back to 🤔 after each result
  → StreamBuffer flushes progressively via sendText/editText (or plain chunked reply)
  → Platform-specific markdown adaptation
  → Response delivered, status reaction 👍 (or 😱 on failure)
  → Run paused for approval? Prompt listing the waiting tool calls
```

Runs use `threadId` from `session.threadKey` (default `` `${channelType}:${userId}` ``), `threadAccess: 'shared'`, and pass `userId`, `channelType` and `channelId` to the run, so tools see them in their context. Scheduled messages (`raw.scheduled === true`, see [Scheduler](/docs/channels/scheduler)) are answered without quoting the trigger message.

## Status Reactions

Show processing progress with emoji reactions on the user's message. Users see immediate feedback instead of waiting 10-30 seconds in silence.

```typescript
const gateway = new Gateway({
  // ...
  reactions: {
    enabled: true,
    emojis: {
      queued: '👀', // message received
      thinking: '🤔', // LLM is generating
      tool: '🔥', // tool execution
      done: '👍', // response sent
      error: '😱', // something failed
    },
    debounceMs: 700, // skip rapid phase changes
    stallSoftMs: 10000, // show 🥱 after 10s on same phase
    stallHardMs: 30000, // show 😨 after 30s
  },
});
```

Supported channels: Telegram (`setMessageReaction`), Discord (`react`), Slack (`reactions.add`, default emojis mapped to Slack names). Channels without `setReaction` silently skip reactions.

## Inbound Debouncing

When a user sends 3 messages in 1 second, without debouncing you get 3 parallel LLM calls. With debouncing, they merge into one.

```typescript
const gateway = new Gateway({
  // ...
  debounce: {
    enabled: true,
    delayMs: 1500, // wait 1.5s of silence before processing
    byChannel: { discord: 2000 }, // per-channel override
  },
});
```

Messages are grouped by `channelType:channelId:userId`. Texts are joined with newlines, attachments are merged. Merged messages then go through `queueMode` like any other message.

## Envelope Formatting

Give the LLM context about who is talking, from where, and when — without polluting agent instructions.

```typescript
const gateway = new Gateway({
  // ...
  envelope: {
    enabled: true,
    includeTimestamp: true,
    includeElapsed: true,
    timezone: 'utc', // 'utc' | 'local' | IANA timezone string
  },
});
```

The user's message `Hello world` becomes:

```
[Feb 28, 20:15 | telegram | Alice | +2m30s] Hello world
```

Use `includeChatType: true` to add `DM`/`group`, and `includeSender`/`includeChannel: false` to drop those parts. Invalid time zones fall back to UTC.

The elapsed time shows how long since the last message in this thread. The LLM naturally picks up on timing and context without explicit instructions.

## Queue Modes

Control how concurrent messages from the same user/thread are handled.

```typescript
const gateway = new Gateway({
  // ...
  queueMode: 'sequential',
});
```

| Mode         | Behavior                                                                                                                             |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `parallel`   | Default. All messages processed immediately.                                                                                         |
| `sequential` | FIFO per thread. Next message waits for current to finish.                                                                           |
| `interrupt`  | Abort current processing (the run's `AbortSignal` fires), start new message immediately. Aborted runs are not reported to `onError`. |
| `collect`    | Buffer messages during processing, merge all when idle.                                                                              |

For personal assistants, `sequential` or `collect` usually give the best UX. Queues are keyed by the same thread id as sessions.

## Multi-channel Setup

```typescript
import {
  Gateway,
  telegramChannel,
  discordChannel,
  slackChannel,
  webchatChannel,
} from '@cogitator-ai/channels';

const gateway = new Gateway({
  agent,
  cogitator,
  channels: [
    telegramChannel({ token: process.env.TG_TOKEN! }),
    discordChannel({ token: process.env.DISCORD_TOKEN! }),
    slackChannel({
      token: process.env.SLACK_BOT_TOKEN!,
      signingSecret: process.env.SLACK_SIGNING_SECRET!,
      appToken: process.env.SLACK_APP_TOKEN!,
    }),
    webchatChannel({ port: 18789 }),
  ],
});
```

## Platform Comparison

| Feature            | Telegram     | Discord   | Slack          | WhatsApp | WebChat   |
| ------------------ | ------------ | --------- | -------------- | -------- | --------- |
| Streaming (edit)   | ✅            | ✅         | ✅              | ✅        | ✅         |
| Reactions          | ✅            | ✅         | ✅              | ❌        | ❌         |
| Typing indicator   | ✅            | ✅         | ❌              | ✅        | ✅         |
| Media (vision)     | ✅            | ✅         | ✅              | ✅        | ❌         |
| Voice (STT)        | ✅            | ✅         | ✅              | ✅        | ❌         |
| Max message length | 4096         | 2000      | 40000          | 65536    | unlimited |
| Public URL needed  | webhook only | no        | HTTP mode only | no       | no        |
| Auth model         | Bot token    | Bot token | 3 tokens       | QR code  | custom    |

## Approvals

Tools marked `requiresApproval` (for example `create_tool` from `capabilities.selfTools`) pause the run before they execute — unless the Cogitator has `guardrails.onToolApproval`, which then decides instead. The Gateway turns that pause into a chat conversation:

1. The paused run's text (if any) is sent, followed by a prompt listing each waiting call — tool name, description and compact JSON arguments (cut at 300 characters).
2. The user replies `approve` / `yes` to run the calls, or `deny` / `no` to refuse, optionally followed by a reason (`no, too risky`) that the agent sees. Replies are case-insensitive and may end with punctuation; `да` / `одобряю` and `нет` / `отклоняю` work out of the box.
3. The Gateway calls `cogitator.resume(agent, threadId, { userId, defaultDecision })` and delivers the result like any reply, streaming included. If the run pauses again, a new prompt is sent.

Any other message on a paused thread runs as usual, and the runtime answers the waiting calls as declined. An approve-only word with more text after it (`yes, but rename it`) counts as a new message, so nothing runs by accident.

Only the user who started the run can answer it: the runtime checks the run's `userId` on resume. In a shared thread (a `threadKey` per group chat), someone else replying `approve` gets `notAllowedMessage` and the pause stays put.

Pauses survive restarts when the runtime persists them (in the thread's memory once the Cogitator has a memory adapter, or in `runCheckpoints`): the first approve/deny reply on a thread the Gateway has not seen since it started tries `resume`, and runs as a normal message if nothing is paused. On threads it has already seen, the Gateway resumes only pauses it sent a prompt for, so one Gateway process should serve a given thread.

```typescript
const gateway = new Gateway({
  // ...
  hooks,
  approvals: {
    approveWords: ['approve', 'yes', 'да'],
    denyWords: ['deny', 'no', 'нет'],
    format: (approvals, { approveWords, denyWords }) =>
      `Разрешить ${approvals.map((a) => a.toolName).join(', ')}? ` +
      `Ответьте «${approveWords[0]}» или «${denyWords[0]}».`,
    notAllowedMessage: 'Подтвердить может только автор запроса.',
  },
});

hooks.on('approval:requested', ({ threadId, approvals }) => {
  audit.log(
    threadId,
    approvals.map((a) => a.toolName)
  );
});
hooks.on('approval:resolved', ({ threadId, decision, superseded }) => {
  audit.log(threadId, decision.approved, superseded);
});
```

`approval:resolved` carries `superseded: true` when the user sent a new message instead of answering (the calls were declined), and has no `approvals` when the pause predates this process.

On channels with buttons (Telegram), the prompt also carries **Approve** and **Deny** buttons. A press answers like the reply words, through the same middleware and checks, and the buttons then show the decision. `approvals: { buttons: false }` turns them off, and `buttonLabels` renames them. A press counts only from the user the prompt asked, while their run is still paused; anyone else gets `notAllowedMessage`, and a press after the request was answered, dropped or the Gateway restarted gets `expiredMessage` (a reply word still works then).

`parseApprovalReply(text, words)` and `formatApprovalPrompt(approvals, words)` are exported for custom channels and UIs, together with `DEFAULT_APPROVE_WORDS`, `DEFAULT_DENY_WORDS` and `DEFAULT_NOT_ALLOWED_MESSAGE`.

With `cogitator up`, set `approveWords`, `denyWords` and `notAllowedMessage` in the `approvals` block of `cogitator.yml`; a custom `format` goes in `RuntimeBuilder`'s `approvals` option, which is merged over the YAML block (see [Wizard Setup](/docs/channels/wizard#config-reference)).

## Lifecycle Hooks

Hooks observe the pipeline without changing it. Create a registry with `createHookRegistry()` and pass it as `hooks`:

```typescript
import { createHookRegistry, Gateway } from '@cogitator-ai/channels';

const hooks = createHookRegistry();

hooks.on('agent:after_run', ({ threadId, output }) => {
  console.log(`[${threadId}] ${output.length} chars`);
});
hooks.on('agent:error', (e) => console.error(`[${e.threadId}]`, e.error.message));

const gateway = new Gateway({ agent, cogitator, channels, hooks });
```

Handlers are typed by hook name: `hooks.on(name, handler)` gives the handler the payload type `HookPayloads[name]`. `HookPayloads` is exported from `@cogitator-ai/channels` and `@cogitator-ai/types`; the per-hook event types below come from `@cogitator-ai/types` (`ApprovalRequestedEvent` / `ApprovalResolvedEvent` also from `@cogitator-ai/channels`). A handler typed with an `unknown` payload (`HookHandler`) is still accepted for any hook.

| Hook                 | Payload type             | Fields                                                                     |
| -------------------- | ------------------------ | -------------------------------------------------------------------------- |
| `message:received`   | `MessageReceivedEvent`   | `{ msg, threadId, user }` — after middleware accepted a message            |
| `session:created`    | `SessionCreatedEvent`    | `{ session, threadId }` — first message of a session                       |
| `session:compacted`  | `SessionCompactedEvent`  | `{ threadId, result }` (`CompactionResult`)                                |
| `agent:before_run`   | `AgentBeforeRunEvent`    | `{ msg, threadId, agent }` (agent name)                                    |
| `agent:after_run`    | `AgentAfterRunEvent`     | `{ msg, threadId, output }`                                                |
| `agent:error`        | `AgentErrorEvent`        | `{ msg, threadId, error }` — `error` is always an `Error`                  |
| `stream:started`     | `StreamStartedEvent`     | `{ msg, threadId }`                                                        |
| `stream:finished`    | `StreamFinishedEvent`    | `{ msg, threadId, messageIds }`                                            |
| `message:sending`    | `MessageSendingEvent`    | `{ msg, threadId, text, channelId }`                                       |
| `message:sent`       | `MessageSentEvent`       | `{ msg, threadId, text, messageId }` (id of the first chunk)               |
| `approval:requested` | `ApprovalRequestedEvent` | `{ msg, threadId, userId, approvals }`                                     |
| `approval:resolved`  | `ApprovalResolvedEvent`  | `{ msg, threadId, userId, decision, approvals?, superseded }`              |
| `action:received`    | `ActionReceivedEvent`    | `{ action, answer(options?) }`: a button press the Gateway does not handle |

Handlers run in registration order and are awaited, so a slow handler delays the reply; a throwing handler is logged and does not stop the others. `message:sending` / `message:sent` fire for replies when `stream` is off and for approval prompts — with `stream` on, the answer reports its messages through `stream:finished`. A thrown non-`Error` value is wrapped in an `Error` before it reaches `agent:error`. `hooks.off(name, handler)` removes a handler.

## Media and Voice Messages

Channels deliver photos, voice notes, videos and files as `attachments`. Pass a `MediaProcessor` to turn them into model input:

```typescript
import { Gateway, MediaProcessor, LocalWhisper, DeepgramSttProvider } from '@cogitator-ai/channels';
import { getModel } from '@cogitator-ai/models';

const mediaProcessor = new MediaProcessor(
  new LocalWhisper(), // offline fallback, used once its model is downloaded
  (modelId) => getModel(modelId)?.capabilities?.supportsVision ?? true,
  new DeepgramSttProvider({ apiKey: process.env.DEEPGRAM_API_KEY! }),
  { maxInlineTextChars: 20_000, maxDownloadBytes: 25 * 1024 * 1024 } // defaults
);

const gateway = new Gateway({ agent, cogitator, channels, mediaProcessor });
```

| Attachment | What the agent gets                                                                                   |
| ---------- | ----------------------------------------------------------------------------------------------------- |
| Image      | Passed as run `images` when the vision check returns `true`, otherwise a system note asking to say so |
| Audio      | Transcribed; the text is prepended to the message                                                     |
| Video      | A system note naming the file (video content is not analyzed)                                         |
| Text file  | Inlined (by MIME type or extension such as `.md`, `.json`, `.ts`), cut at `maxInlineTextChars`        |
| Other file | A system note naming the file                                                                         |

STT providers: `DeepgramSttProvider` (`nova-3`), `GroqSttProvider` (`whisper-large-v3`), `OpenAISttProvider` (`gpt-transcribe`) — each takes `{ apiKey, model? }` (Deepgram also `language`) — or any `{ transcribe(buffer, mimeType) }`. The provider is tried first; without one, `LocalWhisper` (Xenova `whisper-tiny`, cached in `~/.cogitator/models`) transcribes once downloaded. `createWhisperDownloadTool(whisper)` gives the agent a `download_stt_model` tool that installs it on request. When nothing can transcribe, the agent is told so instead of failing. Attachments fetched by URL (Discord) are capped at `maxDownloadBytes`.

`RuntimeBuilder` wires this up automatically, picking `DEEPGRAM_API_KEY`, then `GROQ_API_KEY`, then `OPENAI_API_KEY`.

## Session Management

Gateway integrates with `@cogitator-ai/memory` for persistent conversations:

```typescript
import { SQLiteAdapter } from '@cogitator-ai/memory';

const memory = new SQLiteAdapter({ provider: 'sqlite', path: './sessions.db' });
await memory.connect();

const gateway = new Gateway({
  // ...
  memory,
  session: {
    threadKey: (msg) => `${msg.channelType}:${msg.userId}`,
    compaction: {
      strategy: 'summary',
      threshold: 100, // messages in the thread before compaction runs
      keepRecent: 20, // newest messages kept verbatim
      summaryModel: 'google/gemini-3.8-flash', // optional, defaults to the agent model
    },
  },
});
```

The `threadKey` function determines how sessions are grouped. By default, each user gets their own session per channel.

Compaction summarizes everything except the newest `keepRecent` messages with an LLM call (`summaryPrompt` overrides the default prompt) and stores the summary as a system message. Strategies: `summary`, `sliding-window` (drop old messages), `hybrid`. A failed compaction is reported to `onError` and the message is still answered. Force it with `await gateway.compactThread(threadId)`.

## Stats

```typescript
const stats = gateway.stats;
// {
//   uptime: 7200000,
//   activeSessions: 3,
//   totalSessions: 12,
//   messagesToday: 847,
//   connectedChannels: ['telegram', 'discord'],
// }

gateway.getSessions();
// [{ threadId: 'telegram:42', channelType: 'telegram', userId: '42', userName: 'Ann',
//    messageCount: 18, lastActiveAt: 1767225600000, active: false }, ...]
```

`activeSessions` counts threads with a run in progress; `messagesToday` resets at local midnight.

## Environment Variables

A typical `.env` for a multi-channel bot:

```bash
# LLM
GOOGLE_API_KEY=...

# Telegram
TG_TOKEN=7204891735:AAHr...

# Discord
DISCORD_TOKEN=MTI...

# Slack
SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...
SLACK_APP_TOKEN=xapp-...

# WebChat
WEBCHAT_TOKEN=your-secret-token
```

## Running with the CLI

`cogitator assistant` runs a Gateway you build in code, with a live status view:

```typescript title="src/gateway.ts"
export const gateway = new Gateway({ agent, cogitator, channels });
```

```bash
cogitator assistant                 # loads .env into process.env, imports src/gateway.ts
cogitator assistant -c bot/gateway.ts --quiet
```

The module must export `gateway`; the command starts it, shows stats and a live log, and stops it on `q` or Ctrl+C. For a config-driven assistant use [`cogitator.yml` and `cogitator up`](/docs/channels/wizard) instead.

## Graceful Shutdown

```typescript
process.on('SIGTERM', async () => {
  await gateway.stop(); // flushes debouncer, disposes queue, stops channels
  process.exit(0);
});
```
