Skip to main content

Channels

Channels connect a background app to inbound / outbound I/O - Telegram, Discord, WhatsApp, a webhook, an RSS feed, a cron schedule, or a connector event. In app YAML they are declared under tools.modules.channels.config.providers, and every provider is armed and run by digitorn-background. Delivery is adapter / activation pipeline behavior - see Activation below.

Channels require runtime.mode: background. They are how an app that runs on its own — with no one chatting — wakes up: on a schedule, an incoming webhook, a new message on a bot, an RSS item. If your app "does X every minute/hour/day" or "does X whenever Y happens", it is a background app with a channel, not a conversation. A conversation app has no channels.

cron channel vs the scheduler module — don't confuse them. For a standalone app that runs on a timer, use a cron channel on a background app (this page). The separate scheduler module (schedule tool) does something different: it schedules a recurring wake-up of an existing chat session (a "remind me every morning" inside a conversation), and it is not how an autonomous app runs on a schedule. Rule of thumb: an app on a timer → mode: background + a cron channel.

Built-in adapters​

AdapterTypical use
cronScheduled runs (5-field cron expression)
webhookHTTP POST in, optional reply out
rssPoll a feed on an interval
telegramTelegram bot (long-polling getUpdates)
discordDiscord bot (gateway websocket)
whatsappWhatsApp (Meta Cloud API webhook)
connectorConnector events (Gmail, Slack, GitHub, …)
primitivesInternal polling helper

YAML shape​

yaml
tools:
modules:
channels:
config:
providers:
telegram_main:
adapter: telegram
enabled: true
config:
token: "{{env.TELEGRAM_BOT_TOKEN}}"
interval: 1
activation:
agent: assistant
message: "{{event.message}}"
session: per_event
reply: auto
default_agent: assistant # optional: agent used when a provider omits activation.agent
max_turns: 30
timeout: 120

Each entry under providers has three parts: adapter (which of the built-in adapters arms it), config (adapter-specific connection settings - token, schedule, url, inbound path, ...), and activation (what happens when the adapter produces an event - see below).

config by adapter​

Every field an adapter actually reads - nothing here is guessed, this is the adapter registry's own declared parameter list. A "(secret)" tag marks a secret field: pass it through {{secret.X}} or {{env.X}}, never inline.

cron

FieldTypeRequired
schedulestring (5-field cron expression)yes
timezonestring (e.g. America/New_York)
yaml
config:
schedule: "0 9 * * 1-5"

Always quote schedule, no exceptions - most real schedules start with * (every N minutes/hours: */10 * * * *), and YAML reads a leading unquoted * as its own alias-reference syntax, not the start of a string. That is a parse error, not a validation error - it fails before the cron expression is even looked at. If you already added quotes and it still won't parse, don't add a second pair around the first - re-read the file: the value has almost certainly ended up double-quoted ("\"*/10 * * * *\""), which is itself invalid.

webhook

FieldTypeRequired
inbound_pathstringyes
authstring (none | api_key | signature)
api_key (secret)string
api_key_headerstring
signature_secret (secret)string
signature_headerstring
signature_schemestring (generic (default) | github | stripe | slack | shopify) - picks the verification convention and default header for auth: signature
max_payload_bytesinteger
callback_urlstring
allow_private_callbacksboolean
yaml
config:
inbound_path: /hooks/alerts
auth: none

telegram

FieldTypeRequired
token (secret)string (bot token from @BotFather)yes
intervalinteger (long-poll interval, seconds)
api_basestring (override the Telegram API host)
yaml
config:
token: "{{secret.TELEGRAM_BOT_TOKEN}}"
interval: 1

discord

FieldTypeRequired
token (secret)string (bot token from the Discord developer portal)yes
intentsinteger (gateway intents bitmask)
api_basestring (override the Discord API host)
yaml
config:
token: "{{secret.DISCORD_BOT_TOKEN}}"
intents: 37376

intents defaults to 37376 (guild messages + direct messages + message content) when omitted - only set it to something else if the bot needs gateway events beyond plain messages.

whatsapp (Meta Cloud API)

FieldTypeRequired
inbound_pathstringyes
verify_token (secret)string (webhook verification challenge)
app_secret (secret)string (validates the X-Hub-Signature-256 header)
access_token (secret)string (used to send replies)
phone_number_idstring
api_basestring
api_versionstring (e.g. v20.0)
yaml
config:
inbound_path: /hooks/whatsapp
verify_token: "{{secret.WHATSAPP_VERIFY_TOKEN}}"
app_secret: "{{secret.WHATSAPP_APP_SECRET}}"
access_token: "{{secret.WHATSAPP_ACCESS_TOKEN}}"
phone_number_id: "1234567890"

rss

FieldTypeRequired
urlstring (feed URL)yes
intervalinteger (poll interval, seconds)
yaml
config:
url: https://example.com/feed.xml
interval: 300

connector (connector events)

FieldTypeRequired
piecestring (connector id)yes
triggerstring (trigger id within that connector)yes
trigger_urlstring
intervalinteger
auth_from_installedboolean (reuse the app's own installed connector auth)
authobject
propsobject (trigger-specific settings)

primitives - no config fields. An internal polling helper, not something an app author configures directly.

Activation​

activation turns a raw provider event into an agent turn. Every adapter's event goes through the exact same pipeline, so the fields below apply identically whether the trigger is a cron tick, a Telegram message, or a webhook call.

FieldTypeMeaning
agentstringWhich agent handles this event. Falls back to default_agent if omitted.
messagestring (template)The text sent to the agent as this turn's input - see Template variables.
sessionper_event | shared | a template stringper_event: a fresh session every time. shared: one session per (provider, source) pair, reused across events (e.g. one running conversation per Telegram chat). A template (e.g. "{{event.payload.user_id}}") computes a custom session id.
replynone | auto | explicit | streamWhether/how the agent's reply is delivered back to the channel. auto sends the final reply; stream sends incremental chunks as they're generated; explicit only delivers what the agent explicitly pushes via deliver; none runs the agent but sends nothing back.
ownerstring (template)User id this run is attributed to/billed against. Sanitized after rendering.
contextstring (template)Extra text appended to the agent's context for this turn.
modelstring (template)Override the agent's model for this turn only.
reportsboolRoute file outputs from this turn into a dated report folder.
attachmentslistStatic attachments (by ref) included on every fire.
expose_databoolWhether the raw event data is exposed to the agent beyond the rendered message.
filterlist of conditionsDrop the event before it reaches the agent - see Filtering.
preparelist of stepsRun a tool action first and fold its result into the template scope before rendering message/context/etc. - see Prepare steps.
routeobjectPick the agent dynamically based on a field in the event, instead of a fixed agent.
deliverobjectSend the reply somewhere other than back to the event's own source/adapter.

Template variables​

message, context, model, owner, and session (when it's a template) are all rendered the same way: {{...}} is replaced with the dotted path's value, looked up against the event. Anything that doesn't resolve renders as an empty string - a typo in a path is silent, not an error, so double-check a new template against a real fire before shipping it.

The scope every template sees:

PathAlways available?What it is
event.messageOnly on telegram, discord, whatsapp, and connector (not rss - use event.payload.title there)The event's text, already normalized by the adapter - the actual thing a user typed. This is what you want for a Telegram/Discord/WhatsApp message template.
event.providerAlwaysThe provider's name in YAML (e.g. telegram_main).
event.adapterAlwaysThe adapter kind (telegram, cron, ...).
event.sourceAlwaysAdapter-specific origin id (a Telegram chat id, a webhook caller's IP, ...).
event.timestampAlwaysWhen the event was produced.
event.payload.*Always, shape varies per adapterThe adapter's raw/structured data - see the per-adapter table below. event.data.* is an identical alias.
event.metadata.*Always, mostly emptyAdapter-specific extra fields (webhook sets path).

A whole object or array ({{event.payload}} with no further path, or any nested object) renders as its JSON - useful as a catch-all when you don't want to enumerate every field, or for adapters like connector whose payload shape isn't fixed.

event.payload shape per adapter​

Don't guess these - they're the literal keys each adapter puts on the wire.

AdapterPayload keys you can rely on
cronscheduled_for, scheduled_for_local, timezone, fire_count, app_id, catch_up (true only on a missed-slot catch-up run)
webhookWhatever JSON the caller's POST body contains - there's no fixed shape, it's the caller's own convention. {{event.payload}} (the whole body as JSON) is the safe fallback when you don't control the caller.
rsstitle, link, summary, published, id
telegramThe raw Telegram Bot API Update object (update_id, message.text, message.chat.id, ...) - nested exactly as Telegram sends it. Use event.message instead of trying to path into this for the chat text.
discordThe raw Discord message payload (content, channel_id, author.id, ...). Same advice: use event.message.
whatsappThe raw Meta Cloud API webhook body (deeply nested under entry[].changes[].value...). Same advice: use event.message.
connectorWhatever the connector's own event shape is - varies per connector, no fixed keys. Use {{event.payload}} for a JSON dump, or a prepare step to reshape it first.

Secrets and environment values​

{{secret.X}} and {{env.X}} are a separate, config-only mechanism - they resolve an app secret or environment value (PUT /api/apps/{id}/secrets) into a config field like a bot token:

yaml
config:
token: "{{env.TELEGRAM_BOT_TOKEN}}"

They are deliberately blocked at runtime inside activation.message / context / etc. - a template there can never leak a secret into what an agent (or, worse, a chat participant) sees.

Filtering​

Drop an event before it reaches the agent, without spending a turn on it:

yaml
activation:
filter:
- field: event.payload.status
equals: "active"

Each condition checks one dotted path against equals, not_equals, contains (substring), gt, or lt. The first condition that fails drops the event.

Prepare steps​

Run a tool action before the agent turn and fold its result into the template scope, so message/context/route can reference it:

yaml
activation:
prepare:
- action: database.query
params:
query: "SELECT * FROM users WHERE id = {{event.payload.user_id}}"
as: user
message: "New order from {{user.name}}: {{event.payload.total}}"

Examples​

Multi-channel agent - one agent, several providers, each labeled so the agent (and anyone reading the transcript) knows which channel a message came from:

yaml
providers:
telegram_main:
adapter: telegram
activation:
agent: support
message: "Telegram: {{event.message}}"
reply: auto
discord_main:
adapter: discord
activation:
agent: support
message: "Discord: {{event.message}}"
reply: auto

Cron digest:

yaml
providers:
morning_check:
adapter: cron
config:
schedule: "0 9 * * 1-5"
activation:
agent: main
message: "Daily 09:00 check. Read the inbox, summarise."
reply: none

Webhook using the caller's own JSON:

yaml
providers:
alert_webhook:
adapter: webhook
config:
inbound_path: /hooks/alerts
auth: none
activation:
agent: main
message: "Alert received: {{event.payload.title}}. Investigate."
reply: auto

GitHub push notifications, with real signature verification - signature_scheme: github checks the X-Hub-Signature-256 header GitHub actually sends. A push payload has no action field (that's specific to events like pull_request/issues) - use head_commit, the convenience field GitHub includes with the latest commit already picked out, instead of trying to path into the commits array:

yaml
providers:
github_push:
adapter: webhook
config:
inbound_path: /hooks/github
auth: signature
signature_scheme: github
signature_secret: "{{secret.GITHUB_WEBHOOK_SECRET}}"
activation:
agent: main
message: "Push by {{event.payload.pusher.name}} on {{event.payload.repository.full_name}} ({{event.payload.ref}}): {{event.payload.head_commit.message}}"
reply: none

signature_scheme also accepts stripe, slack, and shopify, each matching that provider's real header name and signing convention - generic (the default when omitted) is a plain HMAC-SHA256 of the raw body, hex-encoded, which covers most other webhook senders.