Documentation menu
Getting started
New in v2.0
Feature deep dives
Reference
Task creation
Creating a task
Field-by-field task creation: the source and target lists, copy versus native forward, forum topics, priority, pause versus disable and per-task content rules.
Verified against the checkout built for v2.2.4 · updated 20 Oct 2026
On this page
A task — a ForwardRule in the domain, a rule in the dashboard — is the unit that decides what is read, where it goes and how it is transformed. This page is the field-by-field reference for creating one; the bot, the dashboard, the CLI and the REST API all write the same object.
The task model
One task is one source list plus one target list. The lists carry the real work (source_chat_ids and target_chat_ids), and the single-value fields (source_chat_id, target_chat_id) mirror the first entry of each list. That mirroring is not cosmetic: all_targets reads the primary first, so any reader that only knows the scalar — including older clients and parts of the dispatcher — would otherwise see an empty route.
- Sources enforce the mirror in their setter; targets go through the sanctioned
set_targets()path, which dedupes the list and re-mirrors the first entry. - On write the API resolves every entry of both lists — a handle becomes the canonical numeric id — and then mirrors
resolved[0]back onto the scalar. - Because both spellings are resolved, a list-only or scalar-only payload is accepted, and a partial update that names only the list also updates the scalar the masked write would otherwise leave stale.
Task fields
These are the fields the REST API round-trips. Anything not listed here is a per-task setting, written through the settings endpoints described further down.
| Field | Type | What it does |
|---|---|---|
| id | string | Server-generated task id. Every CLI verb that takes a reference accepts a full id, a unique id prefix or the task name. |
| name | string | Human label. Defaults to nothing, and the id is used in listings. |
| session_id | string | The authorised Telegram account the task forwards with. Empty means the caller’s default session. |
| source_chat_id | string | Primary source. Always mirrors source_chat_ids[0] — the setter keeps the two in step. |
| source_chat_ids | string[] | Every source the task reads. One task may aggregate several channels without duplicating its settings. |
| source_chat_names | string[] | Display names aligned with source_chat_ids (may be shorter). |
| target_chat_id | string | Primary destination. Mirrors target_chat_ids[0] and is read first by all_targets, so it stays authoritative for legacy clients. |
| target_chat_ids | string[] | Every destination: the fan-out list. Written through the sanctioned set_targets() path, which dedupes and re-mirrors the primary. |
| forward_mode | enum | COPY_MESSAGE (default), DIRECT_FORWARD or CUSTOM_HEADER_COPY. See the mode table below. |
| content_mode | enum | What reaches the target: AUTO (default), TEXT_ONLY, MEDIA_ONLY, TEXT_AND_MEDIA. |
| routing_type | enum | Shape of the route hint: CHANNEL_TO_CHANNEL, CHANNEL_TO_PV, PV_TO_CHANNEL, PV_TO_PV. |
| priority | int | Evaluation priority, default 10. A higher number is evaluated first. |
| execution_order | int | Tie-break between tasks with the same priority; then the older created_at wins. |
| multi_route | bool | Default false. When true, evaluation continues past the first match so several tasks can consume the same message. |
| is_active | bool | Enabled or disabled. A disabled task does not match new messages, and its queued jobs are dropped. |
| is_paused | bool | Paused or not. A paused task keeps its queued jobs and retries them (see paused_until). |
| topic_id | string | One forum topic id for the whole task; the dispatcher sends it as message_thread_id. 0 or unset means no topic. |
| delay_seconds | float | Per-task delay before sending, independent of the rate limit. |
| skip_history | bool | Default true: do not backfill messages that predate the task. |
| since_ts | int | Unix timestamp floor; 0 means no floor. |
| ignore_edits | bool | Do not re-forward edited messages. |
| sync_edits / sync_deletes | bool | Mirror later edits or deletions from the source into the target. |
| trigger_events | string[] | Which events fire the task: NEW_MESSAGE (default), EDITED_MESSAGE, CHANNEL_POST, EDITED_CHANNEL_POST. |
| media_handling | enum | AUTO (default), TEXT_ONLY, MEDIA_ONLY, TEXT_AND_MEDIA, SPLIT_LONG_CAPTION. |
| dedupe_policy | enum | STRICT (default), RELAXED or DISABLED. |
| max_retries / rate_limit_per_minute | int | Delivery retry budget (default 3) and the per-task send ceiling (default 25/minute). |
| use_intermediate / intermediate_channel_id | bool / string | The branding hop: deliver through an intermediate channel so the destination header names that channel, never the original source. |
| version | int | Optimistic-concurrency counter. Every stored write increments it; it is returned on every read. |
| settings | object | Read-only snapshot of every per-task setting the server knows about. Write through the settings endpoints so values are validated against the registry. |
Copy mode vs direct forward
COPY_MESSAGEDefaultRe-posts the content through the cleaner/shortcode pipeline as a new publication. No "Forwarded from" header; text and media can both be modified.
DIRECT_FORWARDNativeCalls Telegram’s own forward_messages(). Fastest, keeps the original author header, and cannot rewrite the text. Fails outright on channels that restrict forwarding.
CUSTOM_HEADER_COPYBrandedA copy with the task’s own header — the mode behind the header/footer shortcode templates.
fallback_mode exists alongside forward_mode and it is deliberately constrained: it may be a copy or a drop, never a native forward. When a direct forward is refused, delivery falls back to a clean copy rather than escalating to something the source channel forbade.
Topics and delivery
A forum supergroup accepts a thread id per message, and the dispatcher passes the task’s topic_idas Telegram’s message_thread_id. A zero or missing value is never sent — Telegram would reject a zero thread id.
Priority and execution order
Matching tasks are ordered by priority descending (default 10, so a higher number wins), then by execution_order, then by creation time. Evaluation stops at the first match unless the task sets multi_route, in which case the remaining matches run too. Raise a task above a more general one with atf rules update <id> --set priority=50.
Pause vs disable vs stop
Two different intentions, two different behaviours, and the difference is what happens to work already queued.
| Control | State it sets | Effect on in-flight work |
|---|---|---|
Disable (toggle / stop) | is_active = false | The task stops matching new messages and the worker drops jobs already queued for it — the log line is [PIPELINE:DROP] … Rule is disabled. Nothing is held for later. |
Pause (pause) | is_paused = true, optional paused_until | Queued jobs are postponed, not discarded: the worker re-queues them with a backoff capped by paused_until and logs [PIPELINE:PAUSED] … Postponing job. Use pause for maintenance; use disable to stop the task outright. |
Restart (restart) | is_active = true, is_paused = false, paused_until = 0 | A clean start: enabled and unpaused in one write. |
Resume (resume) | is_paused = false, paused_until = 0 | Clears the pause and re-queues any job the pause had postponed. |
| Endpoint | Behaviour |
|---|---|
| POST /api/rules/{id}/toggle | Flips is_active. The CLI’s enable/disable verbs are built on this and correct the result client-side, so they are idempotent in intent. |
| POST /api/rules/{id}/pause | Accepts an optional {"until": unix_ts} body for a timed pause. |
| POST /api/rules/{id}/resume | Clears the pause. |
| POST /api/rules/{id}/stop | Sets is_active = false outright. |
| POST /api/rules/{id}/restart | Sets is_active = true and clears any pause. |
| POST /api/rules/{id}/duplicate | Clones the whole task, including its settings and content policy, under a new id. |
Content rules and overrides
A task can carry its own content policy, which decides whether a message goes out at all and how its text is rewritten on the way. The policy lives in the task’s metadata JSON under content_policy, so it travels with the task through backup, restore and clone.
| Setting | What it decides |
|---|---|
| content_policy_enabled | Master switch for the task’s content rules. |
| blacklist_words | Never forward a message that contains one of these words. |
| whitelist_words + whitelist_mode | Forward only matching messages. whitelist_mode is any (at least one word), all (every word) or phrase (the words in order). |
| regex_block / regex_require | Block on a pattern match, or require one. Hostile patterns (nested quantifiers) are refused rather than executed. |
| block_links / remove_links | Drop messages containing a link, or forward them with the links stripped. |
| strip_words / replacements | Remove words from the text, or apply ordered find → replace pairs — literal, or a regex when the find side is prefixed re:. |
| source_policies / target_policies | Per-chat overrides keyed by chat id, matched on the normalised id so -100123 as a number and as a string are the same chat. |
The settings-schema editor
Everything that is not one of the task fields above is a registered setting, and the registry is declared server-side — that is what keeps the dashboard, the mobile app and the Telegram bot from drifting apart.
atf rules settings schema # every registered key, with kind, default, group
atf rules settings schema --group cleanup # one group at a time
atf rules settings get <id> # this task's own values
atf rules settings set <id> clean_buttons=true forward_delay_seconds=30
# hand a value back to its inherited default — the reset list, not a null
curl -s -X PATCH localhost:8080/api/rules/<id>/settings \
-H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
-d '{"reset":["forward_delay_seconds"]}'The schema endpoint needs no task, so a client can build the whole editor before the user owns anything:GET /api/rules/settings/schema returns version, count,groups and a settings array whose entries carry key, kind,default, group, label, choices, minimum,maximum and inheritable. GET /api/rules/{id}/settings answers with four things that matter:
| Key | Meaning |
|---|---|
| settings | What this task stores. Echoing it back cannot pin a value the task merely inherits. |
| effective | The values the forwarder will actually use, with bot defaults applied. |
| sources | Where each value came from — task, bot (an inherited default) or default (the registry). |
| schema | The same field specs as the schema endpoint, so one request renders a complete panel. |
Settings that name a chat, a route or a schedule — bot_token_id, source_policies,target_policies, the automation blocks — are marked inheritable: false, so a bot default can never point another task’s forwards at someone else’s chats.
What happens on save
The write is validated before it is stored
Unknown setting names, wrong types, out-of-range numbers and invalid choices are rejected before any mutation, so a refused request never leaves a task half-updated. A rejected settings write answers with the error code in the body and a non-2xx status, not a 200 carrying
success: false.Endpoints are resolved and the route is checked
Every source and target is canonicalised to a numeric id, duplicates inside a list are refused, and a source that is also a target is refused as a self-loop.
HTTP 400 {"error":"Invalid rule endpoints: loop_detected","code":"loop_detected"}The update mask decides what may change
A masked update (the dashboard sends one) applies only the named fields; without a mask the legacy present-only behaviour applies. Fields the mask does not name are cleared in the gateway so they never even reach the core, and the mirror fields are added to the mask automatically when a list is changed.
The stored version is bumped
Every persisted write increments
versionand updatesupdated_at. The response is the whole task as stored, so a caller can see exactly what was written.GET /api/rules/{id} -> { "version": 4, "updated_at": 1750000000, ... }
Three worked examples
Each one is a complete, runnable sequence — create, adjust, then prove the result before it touches live traffic.
1 · One source into one destination, as a clean copy
atf rules create --name news-mirror --source @MarketNews --target @MyBroadcast
atf rules update news-mirror --set forward_mode=COPY_MESSAGE --set priority=10
atf rules simulate news-mirror --text "Breaking: market surge" --showThe same thing over REST: POST /api/rules with {"name":"news-mirror","source_chat_id":"@MarketNews","target_chat_id":"@MyBroadcast"}. Handles are resolved to numeric ids as the task is written, so @MarketNews is accepted here and stored canonically; PUT /api/rules/{id} carries later edits. Every CLI verb that takes a reference accepts the full id, a unique id prefix or the task name.
2 · Aggregate three channels into two targets, one on a forum topic
atf rules create --name market-desk --source -1001111111111 --target -1003333333333
atf rules sources market-desk add -1002222222222
atf rules sources market-desk add -1004444444444
atf rules sources market-desk list
# the second target and the thread id go on the rule itself
curl -s -X PATCH localhost:8080/api/rules/<id> \
-H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
-d '{"target_chat_ids":["-1003333333333","-1005555555555"],"topic_id":"42"}'The merged list is deduped server-side and target_chat_id is re-mirrored to its first entry. topic_id is a string on the wire and belongs to the task, not to one destination — see the topic note above.
3 · Scrub a feed, then tighten one source only
atf rules settings set market-desk content_policy_enabled=true \
blacklist_words='["sponsored","airdrop"]' \
whitelist_words='["breaking","report"]' whitelist_mode=any \
remove_links=true regex_block='["(?i)join (our|the) vip"]'
atf rules settings set market-desk \
source_policies='{"-1002222222222":{"blacklist_words":["promo"]}}'
atf rules settings get market-desk
atf rules simulate market-desk --text "Sponsored airdrop - join the VIP list t.me/spam" --showOverrides are additive: the per-source policy is one more gate in the chain (task → source → target) and every policy in the chain has to allow the message, so a source override can tighten a task but never relax it.
The REST equivalents are POST /api/rules to create, PUT /api/rules/{id} (orPATCH) to update, POST /api/rules/{id}/duplicate to clone,PUT/PATCH /api/rules/{id}/settings to write settings, and POST /api/simulateto dry-run a message through the live engine.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| The target rejects the post | The bot or the forwarding account is not an administrator with post rights in the destination. Telegram answers ChatAdminRequired (err_admin_required) or ChatWriteForbidden (err_write_forbidden). | Add the account to the destination with permission to post, then restart the task so the client re-resolves it. |
| A task saves but never delivers | A username was stored where an id was expected. The dispatcher converts the id with int(chat_id), so a raw @handle cannot be sent. | Store numeric ids. The REST API resolves @username, t.me/… and bare ids to numeric ids on create and update, so creating the task through the API or the CLI is the fix. |
| Native forward silently becomes a copy | The source channel restricts forwarding, so forward_messages() is refused and delivery falls back to the copy path. | That is the designed behaviour — make sure forward_mode is COPY_MESSAGE so the fallback is the intended mode rather than a surprise. Protected media additionally needs the re-upload ladder. |
| A setting appears to save but does nothing | A misspelt or unregistered setting name. | The server rejects unknown names (unknown_setting) instead of storing them. List what this build actually supports with atf rules settings schema, or GET /api/rules/settings/schema. |
| The task is refused with a route error | A source that is also a target, or a duplicated entry in one list. | The API refuses self-loops (source_target_overlap, loop_detected) and duplicates (duplicate_target:) with HTTP 400 and the reason in code. Fix the list rather than retrying. |
Deeper failure handling — flood waits, migrated chats, protected media, dead-letter replay — is covered in Operations & troubleshooting; the log vocabulary the engine uses for each decision is on the observability page.