> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polpo.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Channels

> Run one agent in Slack, Telegram, Discord, or WhatsApp with verified webhooks, durable sessions, media, and outbound delivery.

Channels connect an agent to a messaging provider. Polpo stores provider
credentials in a project Connection, verifies inbound webhooks through the
official Chat SDK adapter, resolves one agent route, and keeps a stable Session
for the external conversation.

Supported providers are Slack, Telegram, Discord, and WhatsApp Cloud API.

## Runtime contract

The same provider-neutral pipeline handles every supported channel:

1. An opaque webhook URL resolves exactly one project Channel.
2. The official provider adapter verifies the untouched request.
3. Polpo normalizes the message, identity, thread, reply context, and media.
4. The Channel route selects an enabled agent.
5. A stable Session preserves conversation history for that external user and thread.
6. The agent response is delivered through the same official adapter.

Provider tokens, signing secrets, app secrets, and verification tokens never
enter the prompt. Disabling a route prevents that route from invoking its agent;
disabling the Channel stops the installation.

## Tool access

An agent can define `channels.allowedTools` for every messaging Channel using
that agent. Each Channel Route can narrow access further with its own optional
`allowedTools` list. Omitting either policy inherits the upper-level policy; an
explicit empty list denies every tool at that level.

For an inbound Channel turn, effective access is:

```text theme={null}
agent.allowedTools
∩ agent.channels.allowedTools
∩ route.allowedTools
∩ request/trusted execution restrictions
∩ trusted grants
```

The identity resolver may return `allowedTools` with `disposition: "dispatch"`
to apply a tenant-, site-, entitlement-, or grant-specific restriction. A
server-side client-tool handler may return `allowedTools` for the continuation.
Both are trusted, immutable restrictions and can never expand access.

If a Channel preflight starts a Project Loop, Polpo preserves the Session,
canonical history, user, scope, metadata, and trusted grants, then recalculates
the tool policy for Loop mode. The Channel and Route restrictions do not carry
into the Loop.

## Trusted application identity

A provider user ID identifies a Slack, Telegram, Discord, or WhatsApp account;
it does not prove which user, tenant, or resource that person can access in your
application. Configure an optional server-side identity resolver when Channel
turns need application-scoped authorization.

Polpo calls the resolver after webhook verification and before route selection,
Session creation, or model execution. Store the resolver bearer secret in an
active project Connection, then update the Channel:

```bash theme={null}
curl -X PATCH "$POLPO_DATA_URL/v1/channels/management/$CHANNEL_ID" \
  -H "Authorization: Bearer $POLPO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "identityResolver": {
        "version": 1,
        "type": "http",
        "endpoint": "https://app.example.com/internal/v1/agent-channels/resolve",
        "connectionId": "conn_resolver_api_key",
        "timeoutMs": 3000
      }
    }
  }'
```

The same configuration is available from the CLI:

```bash theme={null}
polpo channels update "$CHANNEL_ID" \
  --identity-resolver-endpoint https://app.example.com/internal/v1/agent-channels/resolve \
  --identity-resolver-connection conn_resolver_api_key \
  --identity-resolver-timeout 3000
```

The Connection must belong to the same project. Polpo sends its credential as
`Authorization: Bearer ...`; the token is never stored in Channel config.
Production resolver endpoints must be public HTTPS URLs. Redirects, private
network destinations, oversized responses, invalid JSON, and timeouts fail
closed.

Resolver request:

```json theme={null}
{
  "provider": "whatsapp",
  "channelId": "whatsapp:123456789012345",
  "installationId": "e92a4fa8-ef04-49f5-9c7d-78405459052c",
  "providerAccountId": "123456789012345",
  "providerUserId": "15551234567",
  "threadId": "whatsapp:123456789012345:15551234567",
  "requestId": "wamid.inbound-1",
  "messageText": "Use this image",
  "attachments": [
    {
      "id": "attachment_8f0c...",
      "messageId": "wamid.inbound-1",
      "index": 0,
      "type": "image",
      "mimeType": "image/jpeg",
      "name": "hero.jpg",
      "size": 184320,
      "width": 1200,
      "height": 800
    }
  ]
}
```

Attachment descriptors contain no provider file ID, URL, credential, or bytes.
They let the resolver consume or deny unsupported media before Polpo downloads
it, creates a Session, or calls a model.

`channelId` is the provider-neutral Chat SDK channel identifier.
`installationId` is the Polpo Channel resource UUID. Use `installationId` in
management and outbound paths such as `POST /v1/channels/:installationId/send`;
do not use the provider channel identifier in that URL.

Dispatch an authorized turn:

```json theme={null}
{
  "disposition": "dispatch",
  "user": "better-auth-user-id",
  "allowedTools": ["apply_site_change", "site_context_get", "site_*"],
  "scope": {
    "key": "active-workspace-id",
    "version": "3"
  },
  "metadata": {
    "tenantId": "tenant-1",
    "siteId": "site-1",
    "workingCopyId": "copy-1",
    "grant": "signed-grant"
  }
}
```

`user` is the stable application user. Optional `scope.key` and `scope.version`
partition the provider thread into an application context such as Personal,
Family, or Work. Changing either value creates or resumes a different Session;
returning to a previous pair resumes that pair's Session without leaking history
across contexts.

Or consume a pairing/setup message before it reaches an agent:

```json theme={null}
{
  "disposition": "consume",
  "reply": "WhatsApp account paired."
}
```

Omit `reply` to consume the event silently. A consumed event does not select a
route, create or resume a Session, invoke Memory, call a model, or run tools.

Dispatch metadata is available only through immutable custom-tool invocation
context and hidden bindings. Scope is available as `invocation.scope.key` and
`invocation.scope.version`; metadata remains under `invocation.metadata`. These
trusted values are not copied into model arguments. Private metadata is not copied into Session
metadata, Channel events, or loop checkpoints. A durable loop resume calls the
resolver again and verifies that the application user and scope still match.

Exact provider retries are deduplicated. A completed `consume` response is not
resolved or sent twice; a previously failed event can be reclaimed and
re-authorized.

## Create a Channel

1. Open an agent and select **Channels**.
2. Select a provider and create or reuse its project Connection.
3. Enter the provider channel, chat, application, or phone-number identifier.
4. Configure message coordination, response delivery, typing, and audio behavior.
5. Complete the provider webhook setup shown in the dashboard.
6. Send a test message and inspect the Channel events.

Connections hold credentials once at project scope. Agent routes grant a
specific agent access to one Channel without copying provider secrets.

The agent-native CLI uses the same rollout-controlled management contract as
the dashboard and MCP:

```bash theme={null}
polpo channels add whatsapp \
  --agent support \
  --connection conn_whatsapp \
  --destination 123456789012345
```

## Conversation behavior

Incoming messages can run concurrently, queue, debounce, burst, or drop
according to the Channel policy. Burst mode combines rapid messages into one
turn before agent execution. Queue and drop policies are bounded so one thread
cannot consume unbounded memory.

Response delivery is independent from inbound coordination:

* **Single** keeps one logical response and splits only at provider hard limits.
* **Conversational** splits long output at semantic boundaries using the target
  length and maximum-message preference configured for the Channel.

Segmentation preserves the full response. The delivery record stores all
normalized provider message IDs returned by the transport.

### Read and typing lifecycle

Read acknowledgement and typing are independent Channel policies:

```json theme={null}
{
  "settings": {
    "activity": {
      "readReceipt": "immediate",
      "typing": "before-delivery"
    }
  }
}
```

`readReceipt` is `off` or `immediate`. `typing` is `off`, `immediate`, or
`before-delivery`. The last mode emits typing immediately before each progress
or terminal delivery, which avoids presenting a short-lived typing indicator
during a long Project Loop. Providers without the corresponding capability
ignore the signal. Signal failures are observable but never change execution or
delivery results. Existing Channels without `activity` retain their legacy
`typingEnabled` behavior.

### Acknowledge a long continuation

A server-side Channel client-tool handler can return an acknowledgement together
with the tool result. Polpo delivers it before starting the configured Project
Loop, records it in the canonical Session, and deduplicates provider delivery
across retries and reconnects.

```json theme={null}
{
  "result": {
    "accepted": true
  },
  "acknowledgement": {
    "text": "I'm updating the site. I'll write back when the preview is ready.",
    "actions": [
      {
        "id": "open-site",
        "type": "open_url",
        "label": "Open site",
        "url": "https://app.example.com/sites/site-1"
      }
    ]
  },
  "trustedMetadata": {
    "workingCopyId": "copy-1",
    "grant": "short-lived-signed-grant"
  }
}
```

`acknowledgement` accepts non-empty `text` and up to ten provider-neutral
actions. An acknowledgement is sent only after the handler succeeds. If its
delivery fails, Polpo does not start the Loop.

### Present a Project Loop result

Project Loops can keep structured output for subsequent steps and expose a
separate terminal presentation without an extra Agent step:

```json theme={null}
{
  "result": {
    "data": {
      "$context": "finalization"
    },
    "presentation": {
      "text": {
        "$context": "finalization.response"
      },
      "actions": {
        "$context": "finalization.actions"
      }
    }
  }
}
```

`data` is persisted with the Loop run and remains available for inspection.
`presentation.text` is the only terminal text delivered by a Channel.
Missing bindings, an empty/non-string presentation, malformed actions, or an
unsafe URL fail the Loop deterministically instead of leaking serialized JSON
to the user.

Supported actions are:

```json theme={null}
{ "id": "preview", "type": "open_url", "label": "Open preview", "url": "https://app.example.com/preview" }
```

```json theme={null}
{ "id": "change-site", "type": "postback", "label": "Change site", "value": "change_site" }
```

`open_url` opens an HTTPS URL. `postback` re-enters the Channel as a typed action
event in the same provider thread. Adapters use native controls when available
and preserve an intelligible fallback on providers with fewer capabilities.

### Reply while a Loop is active

For a fail-fast conversational policy, configure the Channel to process inbound
turns concurrently and reject a new turn when the canonical Session already has
a non-terminal Project Loop:

```bash theme={null}
curl -X PATCH "$POLPO_DATA_URL/v1/channels/management/$CHANNEL_ID" \
  -H "Authorization: Bearer $POLPO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "concurrency": {
        "strategy": "concurrent",
        "maxConcurrent": 4
      },
      "activeRunPolicy": {
        "behavior": "reject",
        "reply": "I'm still completing the previous request. I'll write back when it is ready."
      }
    }
  }'
```

The check runs after trusted identity and canonical Session resolution, but
before history loading, Memory, model execution, or tools. It is therefore
scoped by the resolver's `scope.key` and `scope.version`. Polpo handles the
conversational response; the application should retain its own domain lock for
the protected resource or working copy.

## Media and audio

Official adapters normalize text, reply context, images, audio, video, and files.
Polpo downloads media with scoped provider credentials, enforces per-file and
per-turn limits, and passes supported content to the model. Unsupported or failed
attachments are recorded without silently changing tenant routing.

For application-managed assets, configure a trusted attachment handler. Polpo
runs it after trusted identity resolution but before Session creation and model
execution:

```json theme={null}
{
  "settings": {
    "attachmentHandler": {
      "version": 1,
      "type": "http",
      "endpoint": "https://app.example.com/internal/v1/channel-attachments",
      "connectionId": "conn_asset_ingest",
      "timeoutMs": 10000,
      "maxAttachments": 8,
      "maxBytes": 26214400,
      "maxTotalBytes": 52428800,
      "allowedMimeTypes": ["image/jpeg", "image/png", "audio/ogg"]
    }
  }
}
```

The handler receives `multipart/form-data` with a binary `file` part and a
JSON-encoded `context` part. `context` contains the stable idempotency key,
Channel/message descriptor, attachment metadata, and trusted invocation
`user`, `metadata`, and `scope`. Polpo authenticates the call with the selected
same-project Connection and sends the same key in the `Idempotency-Key` header.

The response contract is intentionally small and strict:

```json theme={null}
{ "reference": "asset-site-1-hero" }
```

The reference must be an opaque identifier containing only letters, digits,
`.`, `_`, `:`, `/`, or `-`. Only this reference enters model input. Bytes,
provider identifiers, bearer credentials, and trusted grants remain outside the
prompt. Count, MIME, declared and downloaded size, timeout, redirect, endpoint,
response-size, and schema violations fail closed before model execution. Without
an attachment handler, Polpo preserves its normal image, audio-transcription,
and workspace-file behavior.

With Deepgram configured, audio can be transcribed before agent execution. Voice
responses can use Deepgram or ElevenLabs. Response modality is explicit: text or
audio, never an accidental duplicate of both. Provider capabilities and selected
fallbacks are visible in the Channel configuration.

## Outbound delivery

Send proactive text through the data API:

```bash theme={null}
curl -X POST "$POLPO_DATA_URL/v1/channels/$CHANNEL_ID/send" \
  -H "Authorization: Bearer $POLPO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Your report is ready.",
    "idempotencyKey": "report:2026-08-13:customer-42"
  }'
```

Use a stable `idempotencyKey` to deduplicate one logical send. Polpo returns the
prior delivery for a completed duplicate. If delivery fails, inspect the stored
outcome before retrying with a new key; the original key is never replayed
automatically because one or more provider messages may already have arrived.

Provider-specific targeting:

| Provider | Channel configuration            | Optional request fields                         |
| -------- | -------------------------------- | ----------------------------------------------- |
| Slack    | Channel ID                       | `externalThreadId` for a Slack thread timestamp |
| Telegram | Chat ID                          | `externalThreadId` for a numeric forum topic ID |
| Discord  | Channel ID and optional guild ID | `externalThreadId` for a thread                 |
| WhatsApp | Phone number ID                  | `to` with the recipient phone number            |

`POST /v1/channels/management/{channelId}/test` uses the same verified
installation and delivery path with a default test message. WhatsApp direct
message tests require a recipient:

```bash theme={null}
polpo channels test "$CHANNEL_ID" --to 15551234567
```

Send an approved WhatsApp template outside the customer-service window with an
explicit, retry-safe operation:

```bash theme={null}
polpo channels send-template "$CHANNEL_ID" \
  --to 15551234567 \
  --name reminder_due \
  --language en_US \
  --components '[{"type":"body","parameters":[{"type":"text","text":"Ada"}]}]' \
  --idempotency-key reminder:customer-42:2026-08-19
```

The equivalent API is `POST /v1/channels/management/{channelId}/templates`:

```json theme={null}
{
  "to": "15551234567",
  "idempotencyKey": "reminder:customer-42:2026-08-19",
  "template": {
    "name": "reminder_due",
    "language": "en_US",
    "components": [
      {
        "type": "body",
        "parameters": [{ "type": "text", "text": "Ada" }]
      }
    ]
  }
}
```

Template names, language codes, components, and recipients are validated before
the provider call. The idempotency key prevents a completed logical delivery
from being sent twice, and the provider result is recorded in Channel events and
deliveries. Polpo does not silently downgrade a template to a text message.

## WhatsApp Cloud API

Create a Meta app with the WhatsApp product. Polpo needs:

| Value           | Source                                         |
| --------------- | ---------------------------------------------- |
| Access token    | WhatsApp API Setup or a system-user token      |
| Phone number ID | WhatsApp API Setup                             |
| App secret      | Meta app settings                              |
| Verify token    | A secret value chosen for webhook verification |

Save these values through the WhatsApp Connection flow. Register the exact
opaque callback URL shown by Polpo in Meta, subscribe the app to WhatsApp message
webhooks, and use the same verify token. The GET challenge and signed POST body
are verified before project routing or agent execution.

The Channel remains `pending` while this provider action is incomplete. Polpo
marks it `active` only after the opaque callback returns the exact Meta
challenge using the configured verify token.

Inbound routing is scoped by the opaque installation URL, project, Connection,
Channel, route, external user, and provider thread. Duplicate provider events are
deduplicated using shared transport state. Media downloads use the scoped Cloud
API token. Outbound sends go through the configured phone number ID and are
idempotently recorded in Channel events and deliveries.

<Note>
  `delivery.completed` currently means Meta accepted the outbound API request.
  The official Chat SDK WhatsApp adapter pinned by Polpo does not yet expose
  Meta `statuses` callbacks as normalized delivered, read, or failed receipts.
  Do not interpret the stored status as proof that the recipient read the message.
</Note>

WhatsApp's customer service window, template approval, and recipient eligibility
rules still apply.

## Events and troubleshooting

Channel events include verified inbound acceptance, transport queue/burst/drop
decisions, agent execution, media policy, typing failures, and outbound delivery.
Routine coordination events contain bounded metadata only; credentials, raw media
bytes, and secret-bearing provider payloads are not stored in prompts or event
details.

When a provider retries a webhook, the same provider event ID is deduplicated.
When outbound delivery fails, the event and delivery record retain the error and
whether any message was already delivered. An explicit retry uses a new
idempotency key.

For WhatsApp failures, verify the opaque callback URL, app subscription, app
secret, verify token, phone number ID, token scopes, recipient eligibility, and
the customer service window.
