Skip to content
PricingReleases

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.

ConcernTrigger-onlyChannel
GoalDispatch an agent when an external event firesHold a back-and-forth conversation in a provider thread
Manifesttriggers.module + events[]Above, plus triggers.channelAdapter, channelSetup, and usually channelConnection
Inboundverify() → normalize() → match automationsSame webhook, then ChannelAdapter.normalize() for conversation ingress
OutboundOptional respond() when a trigger-spawned session completesChannelAdapter.deliver() for progress, decisions, and outcomes

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. The gatewayProvider id names credentials the Recursive operator registers — plugin users never paste a shared app secret.
  • triggers.channelAdapter — path to a module exporting createAdapter(settings).

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.

MethodPurpose
providerStable 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.
MethodPurpose
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.
  1. The provider POSTs to the same ingress URL as trigger events:

    POST /api/triggers/inbound/<pluginId>/<connectionId>
  2. triggers.module verify() authenticates the request (signatures, handshakes).

  3. If the plugin declares channelAdapter and workspace_agent_preview is enabled in settings, core loads the adapter and calls adapter.normalize(payload, connection_id).

  4. Core builds a channel binding from plugin settings (allowed channels, mention policy, project routing, budgets, authority ceiling) and runs the message through ChannelGateway for admission.

  5. 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.

Channel behavior is configured through the plugin’s settings.schema. The bundled Slack integration is the reference for naming and shape:

SettingRole
workspace_agent_previewMaster switch — channel ingress is disabled unless true.
authorized_principal_mapJSON map of provider user ids → Recursive principal ids. Unmapped senders are provisional.
allowed_channel_idsJSON list of channel ids the bot may respond in.
mention_policy / free_response_channel_idsRequire an @-mention by default; opt specific channels into ambient response.
conversation_isolationprincipal (per-user context) or shared (one thread for everyone).
default_project_idPin all channel work to a project, or leave empty for automatic routing.
workspace_agent_skill_idsSkills narrowed into channel-started runs.
workspace_agent_authority_ceilingMax authority (read / write / execute) for channel sessions.
active_run_policyqueue follow-ups or interrupt the current run.
per_run_cost_cents / daily_cost_centsOptional budget caps.

Core reads these keys generically — your plugin chooses the schema labels, but the ingress layer expects this vocabulary for Slack-compatible integrations.

The bundled slack-integration plugin (plugins/shared/slack-integration/) is the reference implementation:

  1. Manifest — channelSetup, channelConnection.managedOAuth, triggers.module, triggers.channelAdapter, and event catalog for automations.
  2. triggers/index.ts — verifies Slack’s v0 HMAC signature and answers url_verification handshakes.
  3. channel/index.ts — SlackChannelAdapter implements normalize() for message / app_mention / block_actions, and deliver() for threaded mrkdwn replies.
  4. 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.

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.