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.
cronchannel vs theschedulermodule — don't confuse them. For a standalone app that runs on a timer, use acronchannel on abackgroundapp (this page). The separateschedulermodule (scheduletool) 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+ acronchannel.
Built-in adapters
| Adapter | Typical use |
|---|---|
cron | Scheduled runs (5-field cron expression) |
webhook | HTTP POST in, optional reply out |
rss | Poll a feed on an interval |
telegram | Telegram bot (long-polling getUpdates) |
discord | Discord bot (gateway websocket) |
whatsapp | WhatsApp (Meta Cloud API webhook) |
connector | Connector events (Gmail, Slack, GitHub, …) |
primitives | Internal polling helper |
YAML shape
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
| Field | Type | Required |
|---|---|---|
schedule | string (5-field cron expression) | yes |
timezone | string (e.g. America/New_York) |
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
| Field | Type | Required |
|---|---|---|
inbound_path | string | yes |
auth | string (none | api_key | signature) | |
api_key (secret) | string | |
api_key_header | string | |
signature_secret (secret) | string | |
signature_header | string | |
signature_scheme | string (generic (default) | github | stripe | slack | shopify) - picks the verification convention and default header for auth: signature | |
max_payload_bytes | integer | |
callback_url | string | |
allow_private_callbacks | boolean |
config:
inbound_path: /hooks/alerts
auth: none
telegram
| Field | Type | Required |
|---|---|---|
token (secret) | string (bot token from @BotFather) | yes |
interval | integer (long-poll interval, seconds) | |
api_base | string (override the Telegram API host) |
config:
token: "{{secret.TELEGRAM_BOT_TOKEN}}"
interval: 1
discord
| Field | Type | Required |
|---|---|---|
token (secret) | string (bot token from the Discord developer portal) | yes |
intents | integer (gateway intents bitmask) | |
api_base | string (override the Discord API host) |
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)
| Field | Type | Required |
|---|---|---|
inbound_path | string | yes |
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_id | string | |
api_base | string | |
api_version | string (e.g. v20.0) |
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
| Field | Type | Required |
|---|---|---|
url | string (feed URL) | yes |
interval | integer (poll interval, seconds) |
config:
url: https://example.com/feed.xml
interval: 300
connector (connector events)
| Field | Type | Required |
|---|---|---|
piece | string (connector id) | yes |
trigger | string (trigger id within that connector) | yes |
trigger_url | string | |
interval | integer | |
auth_from_installed | boolean (reuse the app's own installed connector auth) | |
auth | object | |
props | object (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.
| Field | Type | Meaning |
|---|---|---|
agent | string | Which agent handles this event. Falls back to default_agent if omitted. |
message | string (template) | The text sent to the agent as this turn's input - see Template variables. |
session | per_event | shared | a template string | per_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. |
reply | none | auto | explicit | stream | Whether/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. |
owner | string (template) | User id this run is attributed to/billed against. Sanitized after rendering. |
context | string (template) | Extra text appended to the agent's context for this turn. |
model | string (template) | Override the agent's model for this turn only. |
reports | bool | Route file outputs from this turn into a dated report folder. |
attachments | list | Static attachments (by ref) included on every fire. |
expose_data | bool | Whether the raw event data is exposed to the agent beyond the rendered message. |
filter | list of conditions | Drop the event before it reaches the agent - see Filtering. |
prepare | list of steps | Run a tool action first and fold its result into the template scope before rendering message/context/etc. - see Prepare steps. |
route | object | Pick the agent dynamically based on a field in the event, instead of a fixed agent. |
deliver | object | Send 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:
| Path | Always available? | What it is |
|---|---|---|
event.message | Only 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.provider | Always | The provider's name in YAML (e.g. telegram_main). |
event.adapter | Always | The adapter kind (telegram, cron, ...). |
event.source | Always | Adapter-specific origin id (a Telegram chat id, a webhook caller's IP, ...). |
event.timestamp | Always | When the event was produced. |
event.payload.* | Always, shape varies per adapter | The adapter's raw/structured data - see the per-adapter table below. event.data.* is an identical alias. |
event.metadata.* | Always, mostly empty | Adapter-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.
| Adapter | Payload keys you can rely on |
|---|---|
cron | scheduled_for, scheduled_for_local, timezone, fire_count, app_id, catch_up (true only on a missed-slot catch-up run) |
webhook | Whatever 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. |
rss | title, link, summary, published, id |
telegram | The 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. |
discord | The raw Discord message payload (content, channel_id, author.id, ...). Same advice: use event.message. |
whatsapp | The raw Meta Cloud API webhook body (deeply nested under entry[].changes[].value...). Same advice: use event.message. |
connector | Whatever 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:
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:
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:
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:
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:
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:
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:
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.