Building a Channel Plugin
A channel plugin lets people talk to Recursive from an external chat provider — Slack threads, Discord channels, Telegram DMs, and similar. Unlike event-mode automations, which fire a one-shot agent session per webhook, channels maintain durable conversations: inbound messages are normalized, routed through workspace policy, and agent replies are delivered back through the same thread.
Most channel plugins are also trigger plugins: they share the same inbound webhook (triggers.module) for signature verification, but add a channelAdapter module for bidirectional transport.
Triggers vs channels
Section titled “Triggers vs channels”| Concern | Trigger-only | Channel |
|---|---|---|
| Goal | Dispatch an agent when an external event fires | Hold a back-and-forth conversation in a provider thread |
| Manifest | triggers.module + events[] | Above, plus triggers.channelAdapter, channelSetup, and usually channelConnection |
| Inbound | verify() → normalize() → match automations | Same webhook, then ChannelAdapter.normalize() for conversation ingress |
| Outbound | Optional respond() when a trigger-spawned session completes | ChannelAdapter.deliver() for progress, decisions, and outcomes |
Manifest shape
Section titled “Manifest shape”Channel plugins declare three manifest blocks beyond a normal trigger source. See the manifest reference for field-level detail.
{ "id": "my-chat", "channelSetup": { "title": "Get your credentials", "description": "Create a provider app and copy its signing secret.", "guideUrl": "https://example.com/docs/setup", "guideLabel": "Open setup guide" }, "channelConnection": { "managedOAuth": { "gatewayProvider": "recursive-my-chat", "authorizationUrl": "https://provider.example/oauth/authorize", "scopes": ["chat:write"], "identity": { "idPath": "account.id", "labelPath": "account.name" }, "settings": { "bot_token": "access_token" } } }, "triggers": { "module": "./triggers/index.js", "channelAdapter": "./channel/index.js", "setupSkill": "connecting-my-chat", "requiredSettings": ["signing_secret"], "events": [ { "type": "message", "label": "Message posted", "filters": ["channel"] } ] }}channelSetup— copy shown in the channel-instance settings UI.channelConnection.managedOAuth— core runs the OAuth state machine; your plugin maps token-response fields into connection settings. ThegatewayProviderid names credentials the Recursive operator registers — plugin users never paste a shared app secret.triggers.channelAdapter— path to a module exportingcreateAdapter(settings).
The adapter contract
Section titled “The adapter contract”triggers.channelAdapter points at a JS/TS file exporting a ChannelAdapterModule:
import type { ChannelAdapter, ChannelAdapterModule } from '@recursive/plugin-sdk';
export function createAdapter(settings: Record<string, unknown>): ChannelAdapter { return { provider: 'my-chat', capabilities: () => ({ threads: true, buttons: false, editing: false, streaming: false, history_backfill: false, typing: false }), authenticate: async (connectionId) => ({ authenticated: true, provider_account_id: null }), normalize: (event, connectionId) => { /* → NormalizedChannelMessage | null */ }, resolveIdentity: async (connectionId, providerUserId) => null, discoverChannels: async (connectionId) => [], deliver: async (connectionId, request) => ({ provider_delivery_id: '…', rendered_as: 'reply', interactive: false }), };}
const module: ChannelAdapterModule = { createAdapter };export default module;The full interface is documented in the API reference.
Required methods
Section titled “Required methods”| Method | Purpose |
|---|---|
provider | Stable provider id (e.g. slack, discord). |
capabilities() | Feature flags: threads, buttons, editing, streaming, history backfill, typing. |
authenticate(connection_id) | Health-check the connection credentials. |
normalize(event, connection_id) | Convert a raw provider webhook payload into a NormalizedChannelMessage, or null to ignore. |
resolveIdentity(connection_id, provider_user_id) | Resolve a sender display name when the webhook omits it. |
discoverChannels(connection_id) | List channels the bot can see (for the settings picker). |
deliver(connection_id, request) | Post agent output back to the provider thread. |
Optional methods
Section titled “Optional methods”| Method | Purpose |
|---|---|
acknowledge(connection_id, message) | Lightweight receipt (reaction, “working on it” reply). |
recentContext(connection_id, message, limit) | Bounded history excerpt to ground the agent turn. |
backfillHistory(connection_id, channel_id, cursor?) | Paginated history import. |
setTyping(connection_id, channel_id, thread_id, active) | Typing indicator when capabilities().typing is true. |
How inbound messages flow
Section titled “How inbound messages flow”-
The provider POSTs to the same ingress URL as trigger events:
POST /api/triggers/inbound/<pluginId>/<connectionId> -
triggers.moduleverify()authenticates the request (signatures, handshakes). -
If the plugin declares
channelAdapterandworkspace_agent_previewis enabled in settings, core loads the adapter and callsadapter.normalize(payload, connection_id). -
Core builds a channel binding from plugin settings (allowed channels, mention policy, project routing, budgets, authority ceiling) and runs the message through
ChannelGatewayfor admission. -
Accepted messages start or resume an agent conversation; replies go through
adapter.deliver().
Loop prevention is the adapter’s job: normalize() must return null for bot echoes, edits, and other events that should not spawn agent turns.
Settings that drive policy
Section titled “Settings that drive policy”Channel behavior is configured through the plugin’s settings.schema. The bundled Slack integration is the reference for naming and shape:
| Setting | Role |
|---|---|
workspace_agent_preview | Master switch — channel ingress is disabled unless true. |
authorized_principal_map | JSON map of provider user ids → Recursive principal ids. Unmapped senders are provisional. |
allowed_channel_ids | JSON list of channel ids the bot may respond in. |
mention_policy / free_response_channel_ids | Require an @-mention by default; opt specific channels into ambient response. |
conversation_isolation | principal (per-user context) or shared (one thread for everyone). |
default_project_id | Pin all channel work to a project, or leave empty for automatic routing. |
workspace_agent_skill_ids | Skills narrowed into channel-started runs. |
workspace_agent_authority_ceiling | Max authority (read / write / execute) for channel sessions. |
active_run_policy | queue follow-ups or interrupt the current run. |
per_run_cost_cents / daily_cost_cents | Optional budget caps. |
Core reads these keys generically — your plugin chooses the schema labels, but the ingress layer expects this vocabulary for Slack-compatible integrations.
Worked example: Slack
Section titled “Worked example: Slack”The bundled slack-integration plugin (plugins/shared/slack-integration/) is the reference implementation:
- Manifest —
channelSetup,channelConnection.managedOAuth,triggers.module,triggers.channelAdapter, and event catalog for automations. triggers/index.ts— verifies Slack’sv0HMAC signature and answersurl_verificationhandshakes.channel/index.ts—SlackChannelAdapterimplementsnormalize()formessage/app_mention/block_actions, anddeliver()for threaded mrkdwn replies.- Settings —
workspace_agent_preview, channel allowlists, mention policy, and principal mapping.
For automation-only use (no conversation), point Slack’s Event Subscriptions URL at the ingress endpoint and create event-mode automations — see the Slack section in Building a Trigger Plugin. For two-way chat, enable the Workspace Agent preview settings and configure channel bindings.
Validating
Section titled “Validating”plugin_validate({ id }) checks that triggers.channelAdapter resolves to an existing file when declared, lints channelSetup / channelConnection shapes, and warns on missing events or setupSkill mismatches.
Ship bundled channel plugins the same way as trigger plugins: list them in plugins/shared/registry.json and set bundledOptIn: true when the integration should be marketplace-visible but inactive until installed.