# Channels

The **Channels** section of an agent (its *Configuration* group) connects it to messaging surfaces. One section, with the channel picked inside it:

| Channel | What it is |
|---|---|
| **Web/API** | the OpenAI-compatible endpoint, its on/off switch and its bearer key |
| **Telegram** | a BotFather bot |
| **Slack** | a Slack app with Events API over HTTPS |
| **WhatsApp** | via Twilio |
| **Email** | a provider webhook in, SMTP out |
| **HubSpot** | the agent replying in a HubSpot Conversations inbox |
| **Voice (phone)** | inbound calls to a Twilio number, answered by voice |
| **HTTP** | the synchronous REST channel |
| **Agent** | the in-process agent-to-agent channel |
| **A2A** | the agent exposed to *external* A2A clients - see below |

It was briefly six sections, one per channel, and went back to one: each was a toggle plus two fields, which is a row on a page rather than a place to go.

Sensitive fields are hidden behind password-style inputs, and no stored value is ever shown back. A channel's own credentials (a bot token, a signing secret, an SMTP password) are typed on that channel's card and stored encrypted with its configuration. The keys that tools and hooks ask for are set where they are used: a tool's in its panel on the [Tools](https://docs.polyant.ai/admin-panel/tools) section, a hook's on the [Hooks](https://docs.polyant.ai/admin-panel/hooks) section. See [Manage secrets](https://docs.polyant.ai/how-to/manage-secrets).

For step-by-step setup of each provider (BotFather, Slack app, Twilio sandbox), see [Connect a Channel](https://docs.polyant.ai/getting-started/connect-a-channel). This page is the field reference, and each channel below says where its values come from on the provider's side.

## Testing a channel configuration

Channels that authenticate Polyant to a third party carry a **Test** control under their credentials. It exercises the stored configuration and reports one line per check. Present on Telegram, Slack, WhatsApp, Email and HubSpot; absent from **HTTP** and **Agent**, because neither authenticates Polyant to anyone - an HTTP channel's key authenticates callers *to us*. **Voice** has none either: its readiness is on the agent's Status section. The button appears only where the engine has a probe, so it is missing rather than useless.

**It never sends anything.** No message, no test mail, no thread, no rotated secret: it opens an SMTP session and hangs up, or performs read-only API calls. Clicking it during setup cannot reach a customer.

It reads the SAVED configuration, not what is on screen - save first, and it says so when there are unsaved edits.

Each check lands in one of three states, and the third is the point:

| State | Meaning |
|---|---|
| **ok** | proven now, against the provider |
| **failed** | proven broken, with the provider's own error text and a reason: credentials rejected, permission missing, that id does not exist on this account, host unreachable |
| **unverifiable** | nothing here can decide it - check it at the provider |

The last one is not hedging. A probe can only exercise the half of a channel that Polyant initiates. Whether the inbound URL was actually pasted into the provider's console, whether the signing secret on their side matches ours, whether the app is subscribed to the right event: all of that is proven only by an event arriving. A single green verdict spanning both halves would report a healthy channel that can never receive a message, which is precisely the failure this control exists to expose - so the unreachable half is named, and the panel says where to go and check.

For the same reason the result carries **when this channel last had traffic**, inbound or outbound. That is the one fact that does answer "is anything getting through", and a channel that tests clean and has never carried traffic is a channel whose provider side is not finished.

A failed check on an id (a HubSpot channel account, say) lists the ids the provider actually has, because some of those values cannot be read anywhere in the provider's own interface.

What each channel's test actually does:

| Channel | Proven now | Left to the provider |
|---|---|---|
| **Telegram** | the bot token (`getMe`) and that Telegram's registered webhook points to this agent's URL | whether the public URL is reachable from Telegram |
| **Slack** | the bot token (`auth.test`) and the granted bot scopes the adapter needs | the Events API Request URL, event subscriptions (`app_mention`, `message.im`), and signing secret |
| **WhatsApp** | the Twilio credential pair for the mode in force, against the account resource | the inbound URL in the Console, and the sender number - the sandbox number is not owned by the account, so checking ownership would fail on a working sandbox |
| **Email** | one SMTP session, authenticated and hung up; the inbound driver's own API credentials (Mailtrap: the inbox read) and whether a signing secret is present at all | that the inbound URL is configured at the provider, and that their signing secret matches ours |
| **HubSpot** | the token (OAuth mode refreshes it), `conversations.read` via the inbox list, the **channel account id** and the **sender actor id** against the portal | the client secret behind the webhook signature, and the app's subscription to `conversation.newMessage` - HubSpot's own *Test* button on the Webhooks tab covers that half |

Testing needs `agent.channel:write`, not `read`: the probe makes the engine authenticate to a third party with the agent's stored credentials, which is an operator action rather than a read.

The same control exists for [MCP servers](https://docs.polyant.ai/admin-panel/tools#mcp-servers), where it dials the server and lists its tools. One difference is worth carrying across: an MCP server's result is also written by the runtime's own per-turn connect, so the row keeps saying whether the server answered this afternoon. A channel test is only ever a measurement of the moment you clicked it.

## Web/API

The **Web/API** card governs direct HTTP access to the agent: the [OpenAI-compatible endpoint](https://docs.polyant.ai/reference/openai-compatible-api), the native streaming endpoint `POST /api/instances/{slug}/chat/stream` and the CLI. Your own application reaches the agent through this channel. The [Playground](https://docs.polyant.ai/admin-panel/playground) does not: it has its own route on the panel session, so it works whatever this card says. The card holds three things.

**Web channel enabled**. Web is a channel like Telegram or Slack, and this switch decides whether those endpoints answer at all. While it is off they answer **404**, the same answer as for an agent that does not exist, and `GET /v1/models` called with the agent's own key lists nothing. A new agent starts with Web off, like every other channel. Agents that existed when the deployment was upgraded to 1.2.0 were switched on, keeping their authentication setting, so they kept answering. Switching Web on needs the agent's API key while **Require authentication** is on: the engine refuses the switch otherwise. Scripts do the same with `PUT /api/instances/<slug>/channels/web` and the body `{"config":{},"enabled":true}`, after saving the key.

**Require authentication** and its **API Key**. A new agent starts with authentication on, and until a key is saved nobody can reach it. **Generate key** fills the field with a random key: copy it before saving, because once saved it can no longer be shown, only replaced. The switch governs every one of those routes at once. Turning it off asks you to accept the risk first (**I understand the risk, turn it off**), because with authentication off and Web on the agent answers anyone who can reach the engine and knows its slug, spending its budget. That is intended for public agents only; keep authentication on for any agent that should not be public. Turning it on with no key stored or typed is refused on save, since the engine would then answer 401 to every caller. The key belongs to this agent only and cannot be inherited from the organization, because one organization key would open every agent in it. [A2A](#a2a---the-agent-as-a-server) uses the same key but keeps its own on/off switch.

**Context fields (conversation state)**. Each row maps a conversation-state key (left) to a field of the request's `context` object (right, dotted paths allowed, such as `customer.id`), and the mapped values are loaded into the [conversation state](https://docs.polyant.ai/admin-panel/conversations#conversation-state) before the turn. It is the web counterpart of the [HTTP channel's field mapping](#http-synchronous-rest): a voice or web front end uses it to tell the agent's tools and hooks who is on the other end, and keeps streaming. A mapping holds at most 50 rows and cannot target a reserved state key.

A request that carries `context` follows four rules:

- **It needs `chat_id`.** The values are written to a durable conversation, so a request without one is a 400.
- **It needs the agent's API key, even with Require authentication off**, because it writes conversation state. The key field appears as soon as a context field is mapped, and the card refuses to save context fields while no key exists.
- **`context` must be a JSON object, and the agent must map at least one field.** Anything else is a 400. A mapped path that is absent from the request is skipped.
- **The values are written before the turn runs**, so even the first turn's `conversation_start` hooks see them, and they stay in the state if the turn is then abandoned.

The request side, with the exact error messages, is in [OpenAI-compatible API → Call context](https://docs.polyant.ai/reference/openai-compatible-api#call-context).

**Publish on your site or app**. While Web is on, the card also gives what a developer needs to call the agent from their own server:

- **Address to call**: the agent's `/v1/chat/completions` URL under the engine's public address, with a copy button. While the installation has no public address set (in [Installation policies](https://docs.polyant.ai/admin-panel/installation-policies), or `BASE_URL`), the card says so and shows the local fallback, valid only on the engine's own machine.
- **The header to send**: `Authorization: Bearer` with the agent's API key while a key is required (authentication on, or context fields mapped), and no header while authentication is off, in which case a warning says that anyone who reaches the address can talk to the agent.
- **Example (curl)** and **Example (JavaScript)**, each with a copy button. Both are meant to run on your server and read the key from an `AGENT_API_KEY` environment variable, so the key is never printed on the page. Keep the key out of any page a browser loads, since anyone could read it there.

The address comes from `GET /api/instances/{slug}/channels/web/endpoint` (`agent.channel:read`), which returns `endpointUrl` and `baseUrlConfigured`.

## A2A - the agent as a server

> Turning this on publishes the agent to callers outside the deployment. Read the whole section first.

With **A2A** enabled, the agent is reachable by any client speaking the Agent-to-Agent protocol, at two addresses under the engine's public base URL:

| Address | What it serves |
|---|---|
| `GET /a2a/{slug}/.well-known/agent-card.json` | the agent card - name, description, the JSON-RPC endpoint, and the single generic `conversation` skill it advertises |
| `POST /a2a/{slug}/jsonrpc` | the JSON-RPC endpoint that actually runs a turn |

Four things to know:

- **Both are public routes**, in the same sense `/v1/chat/completions` is: the handler validates the caller, not a session guard. Authentication is the agent's own, exactly as for the OpenAI-compatible endpoint. An agent with A2A on and no authentication configured is an open endpoint, and the [Status](https://docs.polyant.ai/admin-panel/instances#status) section reports that as **broken**.
- **The boundary is the organization**, not the workspace: a task belongs to the agent it was created on, and cross-organization reach is refused.
- **The JSON-RPC endpoint is rate-limited** (20 requests/minute) and its task store is bounded, so an external caller cannot grow it without limit.
- **Do not confuse it with the in-process `agent` channel.** That one lets *your* agents call each other inside the deployment. A2A exposes this agent *outward*.

## Telegram

### Where each value comes from

| Value | Where you get it |
|---|---|
| **Bot token** | In Telegram, message `@BotFather` and run `/newbot`, then follow the prompts. The token (`123456789:ABCdef…`) is printed in that chat. `/token` reprints it for a bot you already have; `/revoke` invalidates it and issues a new one. |
| **Allowed user IDs** | A NUMERIC Telegram user id, never an `@username`. `@userinfobot` replies with yours; the engine also logs the sender's id on the first inbound message, which is the quickest way to collect other people's. |

> **A bot in a GROUP receives almost nothing by default.** BotFather enables privacy mode on new bots, and with it a group message reaches the bot only when it is a command, a reply to the bot, or an @-mention. If the agent is meant to read the whole group, turn it off: `@BotFather` → `/setprivacy` → the bot → *Disable*. Direct messages are unaffected either way.

### Fields on the Channels tab

- **Bot Token** *(sensitive)* - the BotFather token.
- **Allowed User IDs** - a comma-separated list of Telegram numeric user ids. When set, the bot only replies to those users; everyone else is silently ignored. Leave blank to accept everyone.

### Starting

Make sure the engine has a public HTTPS address (the one stored in [Installation policies](https://docs.polyant.ai/admin-panel/installation-policies), or `BASE_URL` where none is stored) and that `/webhooks/*` reaches the engine, then set the channel to **On** and save from the page header. The engine registers `https://<base-url>/webhooks/telegram/<slug>` with Telegram when the channel starts. Test by messaging the bot from your own Telegram account.

Each Telegram channel has its own random webhook secret, minted by the engine and kept across saves; Telegram sends it with every update and the engine refuses an update without it. The secret is never shown and cannot be set by a client.

The engine removes that registration from Telegram only when the channel is switched off or deleted, or its agent is deleted. A restart or a rolling deploy leaves it in place, so the replica being stopped cannot unregister the webhook the new one has just set.

### Capabilities

- Text, photos, documents, audio (transcribed by Deepgram if configured, otherwise ignored).
- The fragmented-burst coordinator (see *"The fragmented-burst coordinator"* below) coalesces multiple quick messages into one pipeline run.

## Slack

### What you need

A Slack app with **Socket Mode off** and an Events API Request URL pointing at the engine's public HTTPS endpoint.

Each agent you want to reach directly on Slack needs its own Slack app and its own Events API Request URL (`/webhooks/slack/<instance-slug>`). You can also connect one agent to Slack and let it consult other agents through `ask_<slug>` delegation: enable the **Agent-to-Agent** channel on each agent it will call, then enable the corresponding `agent:<slug>` tools in the Slack agent's **Tools** section and click **Save changes**. See [Agent (in-process)](https://docs.polyant.ai/getting-started/connect-a-channel#agent-in-process) for the setup.

### Where each value comes from

Create the app at [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → *From scratch*, then:

| Value | Where you get it |
|---|---|
| **Bot Token** (`xoxb-…`) | **OAuth & Permissions** → add the bot scopes below → **Install to Workspace**. The token only exists after the install, and adding a scope later requires reinstalling. |
| **Signing Secret** | **Basic Information** → **App Credentials** → *Signing Secret*. |

Bot scopes: `chat:write`, `app_mentions:read`, `im:history`, `im:write`. These four are the ones the channel cannot work without, and the ones the [Test](#testing-a-channel-configuration) control checks. Add `users:read` too if you want replies to address people by name: without it the adapter falls back to the raw user id and still answers.

Two switches decide whether anything ever arrives, and neither is a credential:

- **Socket Mode** → toggle it off.
- **Event Subscriptions** → after saving the channel On in Polyant, enable this, paste the **Slack Events API Request URL** shown on the agent's Slack channel page, then under *Subscribe to bot events* add **`app_mention`** and **`message.im`**. Those are the two the adapter listens for: the first is a mention in a channel, the second a direct message.

Finally, invite the bot into any channel where it should answer (`/invite @your-bot`) - a mention in a channel it is not a member of never reaches it.

### Fields on the Channels tab

- **Bot Token** *(sensitive)* - starts with `xoxb-`.
- **Signing Secret** *(sensitive)* - the app's signing secret. Required to verify every inbound Slack request.

### Starting

Set the channel to **On** and save from the page header. The engine serves `https://<base-url>/webhooks/slack/<slug>` on its public address; Slack verifies this Request URL and then sends events to it. Test by `@`-mentioning the bot in any channel it has been invited to, or by DMing it directly.

### Capabilities

- Threaded replies in channels (`@`-mentions create or continue a thread).
- DMs.
- Text-only inbound (the Slack adapter ignores `files[]` payloads - uploaded images, documents, and voice clips are dropped). Attachments are supported only on Telegram and WhatsApp.
- Tools like `slackPostMessage` can target specific channels (`#general`, channel id, or user id), and are **not bound to the inbound channel** - a conversation that started on WhatsApp, web, or a webhook trigger can fan out to Slack as long as the instance has Slack configured here. See [Tool catalog → Messaging](https://docs.polyant.ai/reference/tool-catalog#messaging) for the full contract, including the optional `slack_allowed_channels` allowlist.

## WhatsApp (via Twilio)

WhatsApp has its own card on the Channels section rather than the generic field list, because it has **two mutually exclusive credential modes** and a field list cannot express that.

### What you need

A Twilio account with the WhatsApp sandbox or a production WhatsApp Business number, plus an inbound webhook configured in the Twilio Console. **Which URL you configure there depends on the credential mode.**

### Where each value comes from

| Value | Where you get it |
|---|---|
| **Account SID** (`AC…`) | Twilio Console home → *Account Info*. |
| **Auth Token** | Same panel, behind *Show*. It is the account-wide credential: it authenticates every Twilio API, so treat rotating it as a change to everything. |
| **API Key SID** (`SK…`) **and secret** | Console → **Settings** → *Account settings* → **API keys & auth tokens** → pick your region → **Create API key** → type *Standard*. The secret is displayed once, at creation, and can never be read back - if you lose it, delete the key and make another. |
| **WhatsApp number** | Sandbox: Console → **Messaging** → *Try it out* → **Send a WhatsApp message**, which shows the shared sandbox number and the `join …` code your phone must send first. Production: the WhatsApp-enabled number registered on your account. Enter it as plain E.164 (`+14155238886`), **without** the `whatsapp:` prefix. |

The inbound URL goes in the Console, not here: sandbox → the same *Send a WhatsApp message* page → **Sandbox settings** → *When a message comes in*, method **HTTP POST**; production → the number's or messaging service's inbound webhook. Paste the URL for the mode you chose, from the table above.

### Credential modes

| Mode | Credentials | Inbound URL | How inbound is authenticated |
|---|---|---|---|
| **Auth Token** | Account SID, Auth Token | `https://<base-url>/webhooks/twilio/<slug>/whatsapp` | Twilio's `X-Twilio-Signature` HMAC over the reconstructed request URL |
| **API Key** | Account SID, API Key SID (`SK…`), API Key Secret | `https://<base-url>/webhooks/twilio/<slug>/whatsapp/<webhookSecret>` | A server-minted secret in the URL path, compared in constant time |

Both routes are live, and **an existing Auth Token channel needs no change**: a saved configuration with no mode recorded is read as Auth Token. Picking a mode is a trade, not an upgrade:

- **Auth Token** keeps the stronger authentication - a signature, with no secret in the URL - but the token is Twilio's account-wide credential, and the signature is computed over the URL the engine reconstructs, which makes it sensitive to `TRUST_PROXY` (see below).
- **API Key** uses a credential you can revoke in Twilio without touching anything else, at the cost of putting a secret in a path that your reverse proxy probably logs.

> **Switching mode invalidates the URL you configured in Twilio, in both directions.** Saving a mode change discards the other mode's credentials - the webhook secret included - so the URL currently set in the Twilio Console starts answering 404 until you paste the new one. Rolling back is not the safe direction; it is the same break again.

Each route answers **404** for a channel that is in the other mode, rather than an error naming the mode, so the URL cannot be used to learn how an agent is configured. An unknown slug, a disabled channel and a wrong secret all answer with that same 404; only a failed HMAC answers 403.

### Fields on the Channels tab

Common to both modes:

- **Account SID** - Twilio account SID, `AC` followed by 32 hex characters; anything else is refused on save.
- **WhatsApp Number** - the sender in plain E.164, `+14155238886` for the sandbox or your production number. **Without the `whatsapp:` prefix** - the field validates as `+` followed by digits and refuses anything else.

Auth Token mode adds **Auth Token** *(sensitive)*. API Key mode adds **API Key SID** (`SK…`) and **API Key Secret** *(sensitive)*.

### The webhook URL, in API Key mode

The secret half of the URL is **minted by the engine** - no client can set it. It is created on the first save in API Key mode and then carried forward across later saves, so editing the phone number does not silently rotate a URL that Twilio is already using.

After saving, the card shows the full URL with a copy button and a **regenerate** control. Both reading and rotating it need `agent.channel:write`, not `read`: the URL is a bearer credential in disguise. Minting, rotation and deletion of the secret are recorded in the audit trail - the key name only, never the value.

> **Rotation takes effect immediately.** The previous URL stops working the moment you confirm, and inbound messages are lost until the new URL is saved in the Twilio Console. Rotate when you have the Console open.

### Starting

Set the channel to **On** and save it from the card's own Save button - WhatsApp is the one channel that keeps an inline Save rather than the page's. Starting registers the instance to listen on its webhook route, and switching the channel off deregisters it.

### Capabilities

- Text, images, audio (Deepgram transcription if configured), documents.
- "Typing..." indicator (Twilio's `Indicators/Typing.json` API): triggered ~1.5 s after the first fragment of a burst, expires after 25 s or on reply.
- Pre-approved templates via `send_whatsapp_template`.

### Behind a reverse proxy

`TRUST_PROXY` decides whether `X-Forwarded-Host` and `X-Forwarded-Proto` are honoured when the engine reconstructs the request URL. That reconstruction is what the Twilio HMAC is computed over, so for WhatsApp the variable **matters only in Auth Token mode** - an API Key channel is gated by a path secret, which no forwarded header can influence. The [HubSpot](#the-target-url) channel relies on it too when no public address is declared. Leave it at `0` when the engine is exposed directly; set it to the number of proxy hops otherwise. See [Environment variables](https://docs.polyant.ai/reference/env-vars).

## Email

> **Managed services.** The email channel is available with Polyant managed services.

Inbound arrives through a provider webhook; outbound goes out over SMTP. The two halves are independent by design - **the inbound provider does not have to be the service that relays your outbound**, so choosing one never locks the other.

Email is the one channel where the correspondent may be a machine, and most of this section is about that.

### What you need

An inbound provider that will POST incoming mail to a URL, an SMTP relay to send through, and an address to send from. Any relay works: Mailtrap, Postmark, SES, Exchange, a self-hosted one.

### Where each value comes from

The outbound half is whatever your relay's SMTP page says - host, port, username, password - and nothing about it is Polyant-specific. The inbound half is where the work is, and it depends on the driver.

**With the `mailtrap` driver:**

| Value | Where you get it |
|---|---|
| **Provider API token** | Mailtrap → **Settings** → *API Tokens*. The channel sends it as the `Api-Token` header to fetch a message's body, so it needs access to the inbound inbox. |
| **Inbox ID** | The numeric id of the inbound inbox - visible in the inbox's URL in the Mailtrap UI, and returned by `GET /api/inbound/inboxes`. It is not the address. |
| **Provider signing secret** | Minted by Mailtrap when you CREATE the inbound webhook, and shown at that moment. Note it then: it is a 32-character hex value you cannot ask for again later, and a lost one means deleting the webhook and recreating it. |
| **Receiving the mail at all** | Mailtrap → **Domains** → your domain → *Domain Verification* → enable **Inbound domain receiving** and add the **MX record** it gives you to your DNS. A custom-domain inbound inbox is a catch-all: every address at the domain lands in it. |

Then create the webhook itself in Mailtrap and point it at the inbound URL below. Mailtrap signs each delivery with `Mailtrap-Signature` - an HMAC-SHA256 of the raw body keyed on that signing secret - and the driver refuses every request when the secret is missing on our side, silently, so this is the field most worth checking twice.

**With the `generic` driver:** nothing to fetch. Your MTA or relay POSTs Polyant's own canonical shape to the inbound URL, and the path secret in that URL is the only thing authenticating it - see the warning above.

> **The address you send FROM must be one your relay is allowed to send as.** A `fromAddress` outside the relay's verified domain is refused at send time by the relay, not by the form - the channel saves cleanly and every reply fails.

### Inbound providers are drivers

The **Inbound provider** field is a list served from the engine's driver registry, not a fixed enum - a driver shipped by a plugin appears in it with no frontend change. Two ship in the build:

| Driver | Signature | Body |
|---|---|---|
| **`mailtrap`** | HMAC-SHA256 of the raw body in `mailtrap-signature`, a real gate | the notification carries no body; the channel fetches it back with the provider API token |
| **`generic`** | **none** - `verify` always passes | the caller POSTs Polyant's own canonical shape |

> **The `generic` driver has no provider signature.** The secret in the URL path is then the *only* authenticator: anyone holding that URL can inject a message that appears to come from any sender. It exists so that adopting the channel is never gated on the driver list - any MTA or in-house relay needs a few lines of glue on your side - but prefer a driver with a signature where the provider offers one, and rotate the URL on any exposure.

### Fields on the Channels tab

Inbound:

- **Inbound provider** - the driver above. Editing it requires re-entering its secrets.
- **Provider API token**, **Inbox ID**, **Provider signing secret** *(sensitive)* - what the chosen driver needs to call the provider's API back. Absent for `generic`, which needs none: they are pruned from the stored configuration rather than kept encrypted forever.

Outbound - the SMTP block is **saved as a whole**, so the password must be re-entered with any change to it:

- **SMTP host**, **SMTP port** (default `587`), **SMTP username**, **SMTP password** *(sensitive)*.
- **Implicit TLS** - on for port 465, which is TLS from the first byte; off for 587, which upgrades with STARTTLS.

Identity:

- **From address** - required. The address replies are sent from, and the address the loop gates treat as "our own".
- **From name**, **Reply-To address** - optional. An empty Reply-To means absent rather than invalid.
- **HTML signature** - optional operator-authored HTML appended to every outgoing reply.

### The inbound URL

`https://<base-url>/webhooks/email/<slug>/<webhookSecret>`

The secret half is **minted by the engine** on first save and carried forward across later saves, so editing the SMTP password does not silently rotate a URL the provider is already using. The card shows the full URL with a copy button and a **regenerate** control; both reading and rotating it need `agent.channel:write`, not `read`, because the URL is a bearer credential in disguise. Rotation is recorded in the audit trail - the key name only, never the value.

> **Rotation takes effect immediately**, and until the new URL is configured in the provider's console inbound mail is lost. Until you configure it the first time, nothing is delivered at all.

### How a thread becomes a conversation

Every other channel hands the engine a conversation key. Email has none, so the channel builds one from RFC-822 headers: the inbound `In-Reply-To` and `References` chain is looked up in an index of every `Message-ID` the agent has seen or minted, and a hit reuses that message's conversation. No hit starts a new one. The conversation id is `<slug>:email:<threadKey>`, where the thread key is a hash - a `Message-ID` is opaque and may contain the characters that separate our own ids.

The index holds **both directions**, because a client replying to the agent's mail references the agent's id, not the customer's. Its uniqueness constraint is also the channel's idempotency: a provider that does not receive the 200 re-delivers the same message, and the duplicate insert failing is what stops the turn from running twice. The rows are dropped with the conversation, by a delete and by [retention](https://docs.polyant.ai/admin-panel/retention#cutoff-and-what-gets-deleted).

The agent's own outbound extends the chain rather than replacing it, so clients thread it correctly. The emitted `References` header is capped at 21 ids - some servers reject very long headers, and the root plus the recent tail is all a client needs.

### Not talking to a machine

Out-of-office responders, mailing lists, bounce daemons and the agent's own address on a Cc are all routine, and two agents answering each other burn tokens until somebody notices. Four nets, in order, none of them involving the model:

1. **Header gates, before the pipeline** - no LLM call, no conversation row, no cost. A message is dropped when it is `Auto-Submitted`, carries a bulk `Precedence`, looks like a mailing list or a delivery report, comes from the agent's own address, carries the agent's own `X-Polyant-Agent` header coming back, has an unparseable sender, or has an empty body.
2. **A per-thread reply cap** - at most **10 replies on one thread per hour**, then silence. This is the net for a machine that looks exactly like a person, which trips none of the header gates.
3. **Quoted history is cut off** before the message enters the pipeline. Feeding the whole appended thread to the model makes it re-read its own previous answers as if the user had just written them, and grows the turn's cost with the thread; the history is already in the database. The heuristic is deliberately conservative - it covers the markers Gmail, Outlook and Apple Mail actually emit, in English and Italian, and returns the body untouched when nothing matches. Losing some quoted noise is cosmetic; cutting a user's real words is not.
4. **A per-message failure ceiling** - after **3** failed turns on the same message the channel stops releasing the provider's claim and lets it die. Releasing the claim is what keeps a transient error from consuming a message forever, but a deterministic failure would otherwise loop through the full pipeline, LLM call included, on every re-delivery. The reply cap cannot see that, because a turn that never delivers writes no outbound row.

### Capabilities

- **Text only.** Attachments are neither read nor sent.
- Outbound is `multipart/alternative`, and both halves are real content: the plain-text part is the agent's markdown **verbatim** - markdown was designed to read as plain text, and every conversion is a chance to mangle the words - and the HTML part is the rendered version plus the signature. Raw HTML in the agent's own output is **escaped**, because that body may quote user-supplied content; the signature is inserted as markup on purpose, being operator-authored configuration.
- **No burst coordinator.** A thread can have more than one writer, so collapsing a burst would merge two people's words into one turn and could answer the wrong one. The channel awaits the pipeline and delivers each reply itself, composed from the message in hand rather than from shared conversation state - see [The fragmented-burst coordinator](#the-fragmented-burst-coordinator).

## HubSpot

> **Managed services.** The HubSpot channel is available with Polyant managed services.

The agent replies in a **HubSpot Conversations inbox**, as a participant in the thread. Webhook in, REST out. It serves both thread kinds in the inbox, chat and email: an email thread carries a subject and a channel account, and both are inherited from the message being answered rather than configured.

Do not confuse this with the HubSpot **tools**, which let an agent read and mutate a CRM portal from any channel and arrive as a plugin rather than with the engine. That is [Connect HubSpot](https://docs.polyant.ai/how-to/connect-hubspot); this is HubSpot as the surface the conversation happens on. They are independent and can be used together.

### Which authentication mode you can use is not a preference

It is an account-level fact:

| Mode | Credentials | Target URL | Routing |
|---|---|---|---|
| **Private app** | Client secret, private app access token (`pat-…`) | `https://<base-url>/webhooks/hubspot/<slug>` | by slug - the app, and therefore its target URL, is per-portal |
| **OAuth app** | Client secret, client ID, refresh token, portal ID | `https://<base-url>/webhooks/hubspot` | **shared** by every connected portal; requests are routed by portal ID, so one portal maps to one agent |

**The client secret is required in both modes**, and it is not a leftover of the OAuth one: `X-HubSpot-Signature-v3` - the only authenticator of the inbound webhook - is an HMAC keyed on it. A private app does expose it, under **Settings → Private Apps → your app → Auth**. It is the *access token* that cannot be used to validate a signature.

> **Saving a mode change discards the other mode's credentials**, so the token you switched away from does not stay encrypted at rest forever. The client secret survives both, since both need it.

### Where each value comes from

For a **private app**: HubSpot → **Settings** → *Integrations* → **Private Apps** → *Create a private app*.

| Value | Where you get it |
|---|---|
| **Private app access token** (`pat-…`) | The app's **Auth** tab, after creating it. |
| **Client secret** | The same **Auth** tab. It is a separate value from the token, and it is the one the inbound signature is keyed on - see above. |
| **Scopes** | The app's **Scopes** tab: `conversations.read` and `conversations.write`. The read scope is what the channel uses to look up the message an event points at; without it inbound arrives and dies. |
| **The webhook** | The app's **Webhooks** tab → *Edit webhooks* → paste the target URL below, then subscribe to **`conversation.newMessage`**. HubSpot will prompt for the read scope here if the app does not already hold it. That page also has a **Test** button, which posts a sample event to your URL - the fastest way to prove the URL half of the wiring. |

For an **OAuth app** the credentials come from the app's *Auth* tab in your developer account (client ID and client secret), the **refresh token** from completing the OAuth install against the customer's portal once, and the **portal ID** is the hub id shown in that portal's account menu.

Two ids are not readable anywhere in HubSpot's interface, and both are asked for here:

| Value | How to find it |
|---|---|
| **Channel account ID** | `GET /conversations/v3/conversations/channel-accounts` on the portal, which lists the connected inbox accounts with their ids. The channel's **[test button](#testing-a-channel-configuration)** runs exactly this call and prints the ids the portal has, which is usually quicker than doing it by hand. |
| **Sender actor ID** | `A-` followed by the numeric HubSpot user id of the account replies should be attributed to (the id is in the URL when you open that user under **Settings** → *Users & Teams*). `GET /conversations/v3/conversations/actors/A-<id>` confirms it exists - again, what the test button does. |

### Fields on the Channels tab

- **Authentication mode** - as above.
- **Client secret** *(sensitive)* - required in both modes.
- Private app mode adds **Private app access token** *(sensitive)*.
- OAuth mode adds **Client ID**, **Refresh token** *(sensitive)* and **Portal ID**.
- **Channel account ID** - optional. The connected inbox account a reply is sent from.
- **Sender actor ID** - optional in the schema but **required to send**: the HubSpot actor a reply is attributed to (`A-…` or `U-…`). Without it the agent receives messages and cannot answer, and the card warns as much.

### The target URL

The card shows it with a copy button; paste it into the app's webhook settings. Unlike the email and WhatsApp inbound URLs it **carries no secret** - inbound requests are authenticated by their signature - so reading it needs only `agent.channel:read`.

The v3 signature is computed over the URL HubSpot called, so the engine has to rebuild that URL exactly. It uses the engine's declared public address: the one stored in [Installation policies](https://docs.polyant.ai/admin-panel/installation-policies), or `BASE_URL` where none is stored. Only when neither is declared does it reconstruct the URL from the request, and behind a reverse proxy that needs `TRUST_PROXY` (see [Behind a reverse proxy](#behind-a-reverse-proxy)): unset, the host can still look right through a tunnel while the scheme stays `http` against a signature HubSpot computed over `https`, and every webhook answers 404. Declaring the public address avoids the question.

### The reply comes back as an event

The channel's defining hazard: the agent's own reply arrives as a `conversation.newMessage` event like any other. Unhandled, the agent answers itself forever. The gate is the message's **direction** - only `INCOMING` is a customer message - and it is the first thing the inbound handler checks.

### Capabilities

- Text. Markdown is rendered to HTML for the thread, with raw HTML in the agent's output escaped.
- **No burst coordinator**, for the same reason as email: a thread is not a single sender. The channel awaits the pipeline and delivers the reply against the thread context it just read.

## Voice (phone calls)

> **Managed services.** The telephone voice channel is available with Polyant managed services.

The agent answers calls to a Twilio phone number. Twilio posts each incoming call to the engine's webhook and then streams the audio over a WebSocket; ElevenLabs transcribes the caller and speaks the agent's answers. The agent receives each of the caller's turns as text through the same pipeline as every other channel, so its prompt, tools, hooks, governance and spend caps apply unchanged. One call is one conversation, on channel `voice`, keyed on the Twilio CallSid.

Between the caller and the agent sits a voice layer that does what a phone call needs and a chat does not: it decides when the caller has finished speaking, says a short wait line while the agent works or a tool runs, handles a caller who talks over the agent, and says the fixed phrases for silence, failures and the start of the call. Its own model calls run on the voice text model the channel selects, whatever the agent's own provider: OpenAI `gpt-5.4-mini` by default, or another OpenAI model, Claude Haiku 5.5 or Sonnet 5.5 on Anthropic, or the same two on Bedrock through EU inference profiles. They go through the AI gateway, appear in the agent's AI logs with the connection that paid for them, and are charged to the agent as service work.

### What you need

- A Twilio account with a voice-capable phone number.
- An ElevenLabs account and the id of the voice the agent speaks with.
- A [provider connection](https://docs.polyant.ai/admin-panel/provider-connections) bound to each voice role the call uses, in the agent's **Connections** section:
  - `voice_tts`: ElevenLabs, the voice the caller hears.
  - `voice_stt`: ElevenLabs Scribe, or Deepgram when the channel runs on Deepgram Flux alone.
  - `voice_turn_detection`: Deepgram, with the default recognition, which transcribes with ElevenLabs and lets Deepgram Flux decide when the caller has finished.
  - `voice_text_model`: the provider of the selected voice text model, OpenAI by default. The workspace must admit that provider and model. A Bedrock connection must be in `eu-central-1` or `eu-west-1`.
  - `voice_decisions`: Typesafe (Jev) or OpenAI (Decisions), as the judges' classifier is set. The judges are optional but on by default: without this role the call runs without them and the agent's Status warns.
- A public HTTPS engine address that passes WebSocket upgrades on `/webhooks/twilio/*` (see the [reverse proxy checklist](https://docs.polyant.ai/operations/deployment#behind-a-reverse-proxy---checklist)).

### Where each value comes from

| Value | Where you get it |
|---|---|
| **Twilio Auth Token** | Twilio Console home → *Account Info*, behind *Show*. Twilio signs every call webhook with it. |
| **Voice id** | The voice's id in your ElevenLabs voice library. |
| **ElevenLabs, Deepgram, Typesafe and model provider keys** | Each provider's API keys page. They go into provider connections: the organization's, created on **Settings → Organization → Connections** and lent to the agent's workspace, a workspace connection usable by that workspace's agents alone, or a private one created from the agent's **Providers** section where the organization allows it. The voice card asks for no key. |

The webhook URL goes in the Twilio Console: the phone number's *A call comes in* setting, **Webhook**, method **HTTP POST**. The card shows the URL to copy, `https://<base-url>/webhooks/twilio/<slug>/voice`. It carries no secret, because Twilio's signature authenticates every call.

### Fields on the Channels tab

The voice card has its own form, saved with the page's Save. Every field shows the value the call runs with, prefilled from the language pack; a field you change is marked **Customized** and can be reset, and a value equal to the default is not stored.

| Section | What it sets |
|---|---|
| **Connection** | The Twilio Auth Token *(sensitive)*, and the phone number for reference only: the number is connected in the Twilio console. |
| **Voice** | The language pack (`it-IT` is the only one today), the ElevenLabs voice and model, an optional typing sound while the agent works and an optional background murmur. |
| **Speech recognition** | ElevenLabs, Deepgram Flux, or ElevenLabs with Deepgram Flux turn detection (the default), and the names and words to expect in every call. |
| **Providers** | The voice roles this configuration uses, each with its bound connection and whether it can run, read again when the recognition, model or judge choices change. Binding happens in the agent's **Providers** section. |
| **Wait lines** | What the voice says while the agent works, by moment of the turn. |
| **Tools** | For each tool of the agent: whether it changes anything (**Read only**, **Write, safe to repeat** or **Write, not safe to repeat**), what happens when the caller interrupts it, and what is said while it runs. The switch **Tell the agent what the caller heard** is here too. See [When the caller interrupts](#when-the-caller-interrupts). |
| **Fixed phrases** | What the voice says on its own: at the start of the call, when the caller goes quiet, and when something fails. |
| **Call data** | Which Twilio parameter of the call fills which of the agent's fields. The values are written into the conversation state before each turn, never into the message text, and the call accepts only the parameters named here. |
| **Judges** | Small classifiers that decide during the call whether the caller finished, spoke to the agent or asked to wait, on Typesafe Jev or OpenAI Decisions. Their thresholds are the platform's and were set for Jev; they need a connection bound to `voice_decisions`. |
| **Prompts** | Every prompt the voice layer sends, prefilled with the default text and editable whole. |

**Import** fills the form from the profile files of the standalone voice module (`voice-profile.json`, `tools.json`, `wait-lines.json`) without saving, and lists what it could not carry over.

### When the caller interrupts

A call runs one turn at a time. When the caller speaks over the agent, the turn in progress is cancelled whole: nothing of it is kept, no tool starts after the interruption, and the caller's new words start the next turn. The exception is a turn that has already started a tool set as **Write, not safe to repeat**: that turn runs to its end and is kept in the history, and the next turn waits for it, so a booking is neither lost nor made twice. A listener's cue said while the agent works in silence (a «va bene», a «d'accordo») does not cancel anything.

A catalogue tool with no setting of its own counts as **Read only**, so an interrupted turn that ran it is cancelled and the agent may run it again on the next turn. Set every tool that books, pays or sends something as **Write, not safe to repeat**. The **Tools** section lists the agent's catalogue tools only; a tool of a remote MCP server has nowhere to be set, so it keeps the previous default: a write not safe to repeat, and after an interruption the agent is told the call may have gone through.

With **Tell the agent what the caller heard** on (the default), the agent's next turn receives, as system context and never as the caller's words, which part of its previous reply the caller heard, whether that turn was cancelled or kept, and which action has no confirmed outcome.

### Starting

Set the channel to **On** and save. The voice channel has no Test control: what a call still needs (the Auth Token, the voice, a voice role without a usable connection, an admitted voice model, a public HTTPS address) is listed on the agent's [Status](https://docs.polyant.ai/admin-panel/instances#status) section, which reads the same checks the call does. When the channel starts, and at every engine start, the engine synthesizes the agent's fixed phrases and wait library in the background, about 3,400 ElevenLabs characters per agent with the default library.

A request the agent does not take - an unknown agent, a channel that is off or not ready, a wrong signature - is answered with the same empty TwiML that hangs up, so the URL cannot be used to learn which agents exist.

The webhook answers an accepted call with a media stream URL, `wss://<base-url>/webhooks/twilio/<slug>/voice/stream/<token>`, whose last segment is a one-time call token: Twilio drops the query string of a stream URL, so the token travels in the path, inside what Twilio signs, and is checked before the WebSocket opens. A stream signed with the agent's Auth Token but carrying no call token means the URL was rewritten on the way, typically by a proxy, and every call to the agent is lost the same way: the engine logs it as an error, `voice.call_lost`, once per agent and reason a minute, so it stands out from ordinary refusals. The signature is skipped only when the engine's public address is a loopback address outside production, for a local test.

### Capabilities

- Inbound calls. The caller's and the called number reach the agent as trusted call metadata.
- With more than one engine replica, a call whose media stream lands on a replica other than the one that received its webhook still works, but its turns carry no phone numbers. Keep a call on one replica with load-balancer stickiness.

## HTTP (synchronous REST)

> **Managed services.** The HTTP channel is available with Polyant managed services, as are [Email](#email) and [HubSpot](#hubspot). Telegram, Slack, WhatsApp and the in-process Agent channel ship in the open-source build.

The **HTTP** channel lets an external system - an orchestrator, a CRM, a contact-centre platform - drive an agent over one REST call, with no adapter to start and no socket to keep open.

### The endpoint

```
POST /channels/http/<slug>/messages
Authorization: Bearer <the agent's API key>
```

Authentication is the agent's API key, the one set on the [Web/API](#webapi) card, and it follows that card's **Require authentication** switch: with authentication off there, this endpoint is open too, and anyone who knows it can write mapped fields into a conversation's state. A slug that names no agent is answered `404 Unknown agent` before the key is checked. The route is rate-limited per IP at 300 requests/minute - high on purpose, because one orchestrator drives many conversations from one address; the API key is the real gate.

The body has two reserved fields plus anything else you want to pass:

- **`session_id`** - the caller's own stable conversation id (for example a Twilio Conversation SID). It becomes the channel id, so the first call creates the conversation and later calls continue it: no server-generated id, no round-trip to learn it. Alphanumeric plus `-` and `_` only, since it becomes part of the conversation id and of the per-conversation workspace path.
- **`message`** - the user text (up to 100,000 characters).
- **`conversation_history`** *(optional)* - up to 20 prior `{ role, content }` turns to **seed** into the conversation, so the agent sees, as its own prior turn, something the orchestrator already sent out of band (a reminder, for instance). It works on any call, not just the first, and turns already persisted are skipped - re-sending the same one is a no-op.

Every **other** top-level field is kept and projected onto the [conversation state store](https://docs.polyant.ai/admin-panel/conversations#conversation-state) through the agent's field mapping.

### Fields on the Channels tab

- **Field mapping** - maps conversation-state keys to dot-paths in the caller's payload (for example the state key `phone` ← `customer.phoneNumber`). The mapped values are projected before the turn runs, so a burst's context is in place whichever fragment triggers it.
- **Outbound** - how the reply gets back to the user. Two shapes:
  - **`sync-response`** - the call blocks for the agent turn and returns `200` with `{ sessionId, conversationId, messageId, reply }`.
  - **An out-of-band transport** (today Twilio Conversations, with an optional WhatsApp typing indicator) - the call acks `202` immediately and the reply is delivered in the background. There is no retry; a delivery failure is logged. The **Twilio Account SID** is not a secret and is shown as saved; the **Twilio Auth Token** is a secret field that shows whether a token is set, and leaving it empty keeps the stored one, so editing the author or the SID does not ask for the token again.
- **Label** - optional human-facing channel name, surfaced to the prompt.
- **Allow per-request model override** - off by default. When on, a request carrying the agent's API key may name the model its turn runs on, in a `model_override` field (`provider`, `model`, and optionally `temperature`, `thinking_enabled`, `thinking_level`), for example to compare models in an evaluation. It requires authentication on and the `sync-response` outbound; a request with `model_override` is refused otherwise, never answered on the agent's own model. The agent's [fast mode](https://docs.polyant.ai/admin-panel/settings#fast-mode) carries over only when the override names the agent's own provider and model.

A channel configuration the engine rejects on save, here or on any other card, is answered with a readable message naming the field rather than the raw validation output, and a new HTTP channel is saved with the transport the editor shows even if the select was never touched.

### Which mode to pick

`sync-response` is the mode to reach for when the caller wants the answer in the same request, and it is the mode where post-model [hooks](https://docs.polyant.ai/admin-panel/hooks) (`replaceResponse`, `regenerate`) and blocking [governance output gates](https://docs.polyant.ai/admin-panel/governance#output-gates-and-streaming) can still act on the text - nothing has been streamed yet.

The out-of-band modes go through the fragmented-burst coordinator (see below), so several quick messages on the same session collapse into one turn. `sync-response` bypasses it: debouncing is incompatible with a blocking request/response, so the caller aggregates instead.

> **Caveat.** Because the coordinator cancels and restarts an in-flight run that has not started a tool yet, a fragment arriving mid-turn re-runs the pre-model hooks. That is harmless for a read-only hook and double-fires a non-idempotent write - the same risk class as the WhatsApp/Telegram coordinator.

Opted-out contacts are suppressed on this channel too: an out-of-band reply to an opted-out session is not delivered. See [Privacy](https://docs.polyant.ai/admin-panel/privacy).

## Agent (in-process)

The **Agent** channel is a virtual, in-process adapter (`packages/engine/src/channels/adapters/agent.adapter.ts`) that lets one instance invoke another from inside its own tool loop - no network hop, no external credentials.

### What it does

When the Agent channel is enabled on an instance, that instance becomes reachable as a **callee** from every other instance via a synthetic tool named `ask_<slug>` (e.g. `ask_support_bot`). The caller passes a free-form question; the callee runs a full supervisor cycle on a fresh conversation scoped to the calling instance, and returns the final assistant message as the tool result.

### Fields on the Channels tab

- **Enabled** - master switch. There are no credentials: enabling it simply publishes the `ask_<slug>` synthetic tool to every other instance in the same engine.

### Notes

- Agent-to-agent calls inherit the **caller's** auth context.
- **Nesting is not allowed.** A callee cannot itself call a third agent: the synthetic tool refuses at depth 1 and returns an error instead of recursing. Each call is also bounded by the agent-to-agent call timeout, an [installation policy](https://docs.polyant.ai/admin-panel/installation-policies) (default 60,000 ms); a callee that does not answer in time is aborted and the caller gets a timeout.
- The call carries `callerSlug`, `callerConversationId`, `depth`, and `parentTraceId`, so analytics and LangSmith stitch the child turn back onto its parent.
- Conversations spawned through the Agent channel use the conversation id pattern `agent:<callerSlug>:<timestamp>` and are visible in the Conversations screen like any other.
- The `agent` channel is exempt from [opt-out](https://docs.polyant.ai/admin-panel/privacy) - there is no end-user contact behind it.
- Agent-to-agent **over the network** (between separate deployments) is not available.

## When the engine disables a channel itself

A channel is normally on or off because a person said so. The engine turns one off on its own in exactly one case: **the adapter failed to initialise at start**, which would otherwise mean a crash loop on every restart.

When that happens the channel records *why* - the adapter's own error text, truncated, and never carrying a credential. Typical causes are an empty or malformed credential (an Account SID that is not `AC…`, a number without a leading `+`) and a provider rejecting the credentials outright.

Two things to know:

- **The reason is only shown on the agent's [Status](https://docs.polyant.ai/admin-panel/instances#status) section**, as a broken check. The Channels section shows the channel as off, with no explanation.
- **Re-enabling clears it.** Fix the credentials, switch the channel back on, and the recorded reason and timestamp are cleared with the save - a resolved fault does not stay stuck to the channel.

## When a stored configuration cannot be decrypted

Channel, event-source and MCP configurations are encrypted at rest. If the installation's `ENCRYPTION_KEY` no longer matches the one they were saved under, decryption fails.

It used to fail as an empty object, which is indistinguishable from "never configured" - and the two repairs are opposites: re-entering the data, versus restoring the key. The configuration is now flagged as **unreadable** instead, and the agent's Status section reports it as broken, naming how many and which.

The Channels section still renders the fields as empty. Status is the only place that tells the truth, so check there before re-typing credentials that may not actually be gone.

## Stopping channels

Each channel card has a **Stop channel** button. Stopping unhooks the adapter, and the channel's webhook route answers **404** from then on: Telegram, Slack and WhatsApp alike. Switching a Telegram channel off (or deleting it, or its agent) also removes the webhook registered with Telegram; an engine restart or a rolling deploy does not. Conversation state is preserved - restarting the channel resumes where it left off.

## Multi-channel behavior

A single instance can have every channel running at once. Each conversation is scoped to a `(instanceId, channel, externalUserId)` triple, so the same end-user talking to the same bot via Telegram and via Slack has two independent conversations and two independent memories.

## The fragmented-burst coordinator

WhatsApp and Telegram users often split a single thought across two or three quick messages:

```
hey
do you have time
to look at the spreadsheet?
```

Without coordination, the engine would launch three separate pipelines. Instead, the **inbound message coordinator** waits for a 2-second silence (the default **Burst window**) before firing. Fragments that arrive after the pipeline has started **abort** the in-flight run and re-arm the timer, up to **Max restarts** (default 3) consecutive cancels. Both are per-agent values in [Behaviour overrides](https://docs.polyant.ai/admin-panel/settings#behaviour-overrides). Beyond that, fragments accumulate and are flushed in a follow-up run.

A run that has started a tool is never cancelled. A cancelled run is not saved, so what its tool did (a booking, a message sent) would be missing from the history the restarted run reads, and the same words could do it again. From the moment the first tool starts, new fragments wait: the run finishes, replies and is saved, and the fragments get a run of their own after it. A tool that a cancelled run asks for does not start; the model receives `{ error, notRun: true }` in its place.

The user sees one thoughtful answer instead of three scattered ones.

The coordinator is **off** for Slack, for the OpenAI-compatible API (where messages are already complete units), and for the HTTP channel's `sync-response` mode. It is **on** for the HTTP channel's out-of-band modes.
