Run a Telegram bot inside an omp coding session. Incoming DMs and configured group @-mentions are injected as user messages; assistant replies stream back to Telegram in real time. One paired DM owner controls the bridge; optional group chat access remains separately configured from the terminal, never by the model.
- Inbound: DMs / group mentions → injected as
<telegram-message …>user turns (photos attached inline; other files downloaded to an inbox). - Outbound: assistant output streams live — native message drafts for DMs (Bot API 9.3+), edited-message previews for groups — then one finalized MarkdownV2 message per turn. A headless host can turn all of that off with
set profile daemon, leavingtelegram_send/telegram_askas the only way out. - Control: local
/telegramconfiguration, owner-only Telegram commands (/spawn,/sessions,/cleanup,/stop,/status), and three model tools (telegram_send,telegram_react,telegram_ask). - Zero runtime dependencies — the raw Bot API over Bun's
fetch/FormData.
flowchart LR
TG[Paired Telegram DM] --> D[Standalone poller daemon]
TG --> T1[Session topic]
D --> H[herdr]
D <--> Q[Filesystem route queues]
Q <--> O1[omp session]
T1 <--> O1
The laptop-wide daemon normally owns Telegram getUpdates, handles global
commands in omp control, and routes session-topic messages through persistent
filesystem queues. Session extensions own agent execution and outbound delivery.
If the daemon is ineligible or unavailable, one live omp session acquires the
same poll lock and provides the identical routing behavior.
- omp ≥ 17.0.0, Bun ≥ 1.3
- A Telegram bot token from @BotFather
- herdr for
/spawnand/sessions(chat bridging works without it)
Install the published extension:
omp plugin install omp-telegram
omp plugin list # → omp-telegram@0.12.4There is no build step and no runtime dependency install. The extension uses only the raw Bot API and Node/Bun built-ins.
- Message @BotFather and send
/newbot. - Choose a name and a username ending in
bot. - Copy the token it gives you (
123456789:AAH…). - Recommended:
/setprivacy→ select your bot → Disable if you want the bot to read all group messages (only needed for--no-mentiongroups; see below).
Inside an omp session:
/telegram token 123456789:AAH... # validates via getMe, stores ~/.omp/agent/telegram/.env (0600)
/telegram on # start polling now, and autostart in future sessions
/telegram token reports @yourbot ok on success. /telegram on sets
access.enabled = true and starts the standalone daemon when owner-DM topics
are enabled and no groups are configured. The daemon stays alive after every omp
session exits. Groups and non-topic configurations deliberately use the
session-poller fallback because their messages need a live target session.
The bridge is enabled when any of these is true at session start:
| Method | Scope |
|---|---|
omp --telegram |
this session only |
OMP_TELEGRAM=1 env |
this session only |
/telegram on (sets enabled) |
every session, until /telegram off |
If no token is configured it stays passive and warns once (no bot token).
Use /telegram daemon status|restart|stop to manage the standalone process.
Topic messages still use their topic ID and session claim. Only private messages without a topic use the persisted DM-owner session.
The first enabled omp session claims DM ownership. A resumed session reclaims ownership when its session file matches the saved record. If the saved owner is not running, the bot refuses the message and tells you how to resume or replace the owner.
- Run
/telegram ownto pin the current session. - Run
/telegram own statusto show the saved session and process state. - Run
/telegram own clearto remove the pin.
Set OMP_TELEGRAM_DM_OWNER=1 in a fleet or conductor session to force its claim
at each start. This variable does not enable the bridge.
Default DM policy is pairing, with exactly one operator:
- The prospective owner DMs the bot anything → the bot replies with a 6-character code.
- You run
/telegram pair <code>in omp → the bot confirms the owner in their DM. - From then on that owner's DM reaches omp and gains the private control commands.
Once an owner is paired, other DMs are silently dropped and cannot mint pairing
codes. Transfer ownership locally with remove <owner-id>, then pair the replacement.
Codes expire after 1 hour; at most 3 are pending before an owner is established.
| Subcommand | Effect |
|---|---|
/telegram or status |
Running state, bot username, policy, owner, pending codes, groups, config, lock holder |
token <bot-token> |
Validate (getMe) then store the token; run on to start |
on / off |
Start / stop polling now; persists enabled |
doctor |
Diagnose token, webhook, daemon, poll lock, state files, optional binaries, and herdr locally; never prints the token |
daemon [status|restart|stop] |
Inspect, restart, or stop the standalone poller |
own [status|clear] |
Pin the current session for private DMs without a topic, show the pin, or remove it |
pair <code> |
Approve a pending pairing; the bot confirms in-chat |
deny <code> |
Drop a pending code |
allow <user-id> / remove <user-id> |
Set or remove the sole paired Telegram account; a second account is refused |
policy <pairing|allowlist|disabled> |
Set DM handling |
group add <id> [--no-mention] [--allow a,b] |
Allow a group; optionally drop the mention requirement / restrict senders |
group rm <id> |
Remove a group |
set <key> <value> |
Tune delivery/UX (see below) |
notify <chat_id> / notify clear / notify off|away|always |
Destination and mode for mirroring locally-started runs to Telegram. away/always mirror ask prompts (shown on the terminal AND Telegram) plus idle/blocked pings; off is the default. away auto-clears when you next type at the terminal; always stays until turned off. /away is the quick toggle for away. See Notifications. |
topics on / topics <chat_id> / topics off / topics tidy on|off |
Per-session forum topics: claim one topic per omp session, routing each session's traffic to its own thread. on auto-hosts in your paired DM (no id needed); <chat_id> hosts in a specific chat (e.g. a forum supergroup). tidy on deletes (DM host) or closes (group host) a session's topic when it exits; a re-adopted closed group topic is reopened. Off by default. |
Every mutation persists to access.json and takes effect on the next inbound
message (the poller re-reads access per message).
These commands are accepted only in the paired owner's private DM. Known bot commands typed in a group are consumed and never become omp user turns.
Unpaired chats see a minimal command menu — only /start; the full
menu is scoped to the paired owner's DM.
With owner-DM topics enabled, the bridge creates one persistent omp control
topic. Use it for /spawn, /sessions, /cleanup, /status, /help, and
/whoami; use session topics for agent conversations and session-local /stop,
/compact, /model, and /thinking commands. A global command
entered elsewhere posts its result in omp control and leaves a short redirect
notice in the originating topic.
| Command | Effect |
|---|---|
/spawn [space] |
List open herdr spaces with inline buttons, or confirm an exact label. A space with live omp sessions requires confirmation before starting another. |
/spawn new <branch> [space] |
Create an unfocused git worktree from the selected source space, then run omp in its root pane. |
/spawn dir <absolute-path> |
Create an unfocused herdr workspace rooted at an existing absolute directory, then run omp there. |
/sessions |
Compare live herdr omp processes with live, unattached, outside-herdr, and stale Telegram topic claims. |
/cleanup |
Tidy the topics of exited (stale) sessions: delete them in a DM host, close (park, history kept) them in a forum supergroup. Live sessions and omp control are never touched. The bare command previews with a confirm button; the tap acts only on the previewed topics that are still stale, so it never deletes a topic that went stale after the preview or one that has since resumed. /cleanup go skips the preview and tidies all currently-stale topics. |
/stop |
Abort the current task. Run it inside the omp topic to identify the owning session. |
/compact [focus] |
Compact the owning session's context while it is idle. Optional text focuses the summary. |
/model [provider/id] |
Show a paged model picker, or switch directly to an authenticated model specification. |
/thinking [level] |
Show a thinking-level picker, or set inherit, off, minimal, low, medium, high, or xhigh. |
/status |
Show the paired owner, bridge state, topics state, live omp count, and topic-owner count. |
/help |
Show the owner command summary. |
/whoami |
Show Telegram chat and user IDs. |
/spawn uses Telegram's native inline keyboard, not a Mini App. The poller
revalidates each short-lived selection, creates an unfocused herdr tab,
worktree, or workspace, and runs omp in its root pane. The new process loads
this extension and claims its topic. Repeated or expired callback actions cannot
spawn twice. Branch names and directory paths are passed as argv, never
interpolated into a shell command.
When the paired owner sends a normal message to a stale owner-DM topic, the
standalone daemon queues the message, revalidates the topic's saved herdr space,
and starts omp --resume with the exact saved session file. The resumed process
reclaims that same topic and consumes the queue. Concurrent messages queue
without starting duplicate processes. /stop never starts an idle session, and
configured groups never receive process-spawning authority.
| Key | Values | Default |
|---|---|---|
streaming |
true (live preview) | false (per turn) | final (last message only) | explicit (nothing automatic) |
true |
profile |
daemon (headless host: forces explicit, no idle notify post, telegram_ask always on) | default |
default |
deliverAs |
steer | followUp — how inbound queues while the agent is busy |
followUp |
chunkMode |
length | newline |
newline |
textChunkLimit |
1–4096 |
4096 |
replyToMode |
off | first | all — threading for telegram_send replies |
first |
ackReaction |
a whitelist emoji (empty to disable) — reaction on receipt | unset |
mentionPatterns |
JSON array of regexes that also satisfy group mention-gating, e.g. ["\\bassistant\\b"] |
unset |
transcribeCommand |
JSON argv array for voice notes, e.g. ["whisper-cli","-f","{file}"]; empty value disables it |
unset |
Voice transcription runs only for Telegram voice notes. Every {file} substring
is replaced with the downloaded inbox path and the command executes directly,
without a shell, with a 120-second timeout and 1 MiB output cap. Successful text
is appended to the agent prompt as [Voice transcript: …]; failures remain
visible as [Voice transcription failed: …] while the original attachment is
still delivered.
Run /telegram doctor in omp first. It validates getMe, reports webhook
conflicts that block long polling, checks daemon and poll-lock liveness, verifies
state-file permissions and JSON, identifies whether configured transcriber and
herdr binaries resolve, and probes herdr. It performs no mutations and never
prints the bot token. /telegram daemon restart replaces a stale or
wrong-version daemon; a live session poller takes over automatically whenever
the daemon is unavailable.
The poll lock stores the PID, a random per-process nonce, and the session identity. Its mtime is a heartbeat that updates every 15 seconds. The nonce prevents a different process with the same numeric PID from re-entering, refreshing, or releasing the lock. A contender cannot reclaim a lock with a heartbeat younger than 45 seconds, even when its PID probe fails.
If Telegram returns a 409 polling conflict, the losing poller stops its
heartbeat and releases its lock. If the lock record identifies a different live
owner, a session poller reports that owner. Then the poller waits before it retries.
Symptom: running /spawn from Telegram creates a new empty thread instead of
just showing the space picker, which appears in omp control.
Cause: your bot has "allow users to create topics" enabled, so when you send
a command outside an existing topic, Telegram spins up a throwaway topic to hold
that message before the bridge sees it — the bridge never created it. /status
and /telegram doctor flag this whenever the setting is on.
Fix: in @BotFather → your bot → Bot Settings, turn off "allow users to create topics". The bot creates one topic per omp session itself, so this setting is never needed and only produces stray command topics. Type commands inside the omp control topic and the picker appears right there. Delete any leftover stray topics from the Telegram client.
/telegram group add -1001234567890 # mention-gated (default)
/telegram group add -1001234567890 --no-mention # respond to every message
/telegram group add -1001234567890 --allow 111,222 # only these user IDs
Only configure trusted groups and trusted sender IDs. A permitted group message is a normal omp user prompt with the session's workspace and tool access: it may read files, run commands, or modify code. Mention gating prevents accidental activation; it does not make an untrusted sender safe. Prefer
--allow <user-ids>, and never use--no-mentionin a public or untrusted group.
Find a group's ID: add the bot, send a message, then check
~/.omp/logs/omp.$(date +%F).log for ignored message from unconfigured group <id>.
--no-mention also requires BotFather /setprivacy → Disable so the bot
actually receives non-mention messages. In mention-gated groups, an @botname
mention, a reply to one of the bot's messages, or a mentionPatterns match all
count as a mention.
telegram_send— send text and/or files to the active or durably claimed chat (or a givenchat_id). Text is chunked and rendered as MarkdownV2 (plain-text fallback on parse errors).filesare absolute paths: images send as photos, everything else as documents (≤ 50 MB each).telegram_react— react to a message with a Telegram whitelist emoji (👍 👎 ❤ 🔥 👀 🎉 …).telegram_ask— ask the user one or more questions with inline keyboards: give 2-8 options for a single- or multi-select choice, or omit options for a free-text question the user answers by replying with a message. It replacesaskon Telegram-originated turns and while away/always mode is on, showing the question on both the terminal and Telegram at once and returning whichever the user answers first. Otherwise it stays mounted alongsideaskwhenever the bridge is running with a paired owner, so a locally injected turn (a scheduled tick or extension-composed prompt) can still reach Telegram. Requests are responder-, chat-, topic-, message-, and nonce-bound, stay answerable while the owning session runs, and use the shared state directory for cross-process answers. Every result starts with anAsk provenanceJSON record.postedlists only surfaces that successfully received the question,answeredBynames the accepted answer's origin, anderrorspreserves per-surface posting failures. A terminal answer therefore cannot hide a failed Telegram post: the result includes aSURFACE ERROR [telegram]line alongside the answer.
When a tool target is omitted, the bridge resolves it in this order: the active
inbound conversation, this process's session topic, an exact durable
sessionFile/sessionId topic claim, then an exact-session DM-owner claim.
Explicit arguments always win. A claim for another session is ignored rather
than widening delivery to the main chat; with no safe target, the tool fails
closed.
telegram_send and telegram_react refuse any chat the inbound gate would not
deliver from. telegram_ask responds only to the exact user who originated the turn.
~/.omp/agent/telegram/ (override with OMP_TELEGRAM_STATE_DIR, mode 0700):
| Path | Purpose |
|---|---|
.env |
TELEGRAM_BOT_TOKEN=… (0600; the real process env wins over this file) |
access.json |
Access + config state (atomic writes) |
inbox/ |
Downloaded attachments; each file is ≤ 20 MiB, and startup/download cleanup removes files older than 7 days then prunes oldest files above 250 MiB total |
prompts/ |
Cross-process selectable-question requests (live while their owning session is) and their answers (GC'd after a short grace) |
bot.lock |
Poller PID, random process nonce, owner identity, and heartbeat mtime |
dm-owner.json |
Session that receives private DMs without a topic |
daemon.json |
Standalone daemon PID, plugin version, and start time |
daemon.log |
Rotating daemon output (5 MiB, one previous generation) |
threads.json |
Topic registry — which session (pid/cwd) owns which forum topic |
route/<thread_id>/ / route/dm/ |
Cross-process routed-message spools for topics and untopiced private DMs |
route/<thread_id>/last-inbound.json / route/dm/last-inbound.json |
Receipt for the most recent delivered inbound message (see below) |
A routed payload is deleted the moment it is consumed, so after a successful delivery the only surviving copy used to be inside the receiving agent's own transcript, in that agent's private format. A supervising process asking the reasonable question "did the operator's reply actually arrive?" had to parse another program's log to find out.
So one bounded receipt is written per route, before the payload is handed to the session:
{
"messageId": 12345,
"date": 1700000000,
"fromId": 555,
"chatId": 100,
"messageThreadId": 7,
"textSha256": "9f86d081…",
"receivedAt": 1700000000123
}- Written before the handoff, because the case a receipt exists for is a consumer that died. One written afterwards records only the deliveries that already succeeded.
- A hash, not the text. The message already lives in the consuming agent's transcript, and a supervisor verifying a challenge code knows the code it sent — hashing its own copy is enough. A hash also cannot leak a message to anything that did not already know it.
- Bounded by construction: exactly one file per route, replaced in place. No reaper is needed beyond the existing route purge, which removes it with the rest of the route state.
- Written
0600inside the0700route directory, tmp+rename, so a reader never sees a partial file.
This file is a stable contract: read it rather than scraping a session transcript.
- DMs use native message drafts (
sendMessageDraft): a live "typing" bubble that updates as the model generates. If the running Bot API lacks drafts, the bridge latches to the edit path automatically. - Groups (and draft-unsupported DMs) send a preview message and edit it in
place with a
▍cursor, throttled and split when it would exceed 4096 chars. - Each assistant turn finalizes into its own real Telegram message, so a
multi-step run reads as a sequence of messages. Three settings trim this.
set streaming falsekeeps one message per turn but drops the live preview.set streaming finaldrops both and sends only the run's last message once the agent finishes.set streaming explicitsends nothing on its own: text reaches Telegram only when the model callstelegram_sendortelegram_ask. The typing indicator still runs infinalandexplicitmodes. explicitis deliberately silent rather than best-effort. It has no end-of-run fallback, because the run most likely to lack an explicit send is one a message steered into mid-task, where the last visible text is that task's closing line and not an answer to anyone. A missing reply is visible to the person waiting and can be asked again; a leaked internal turn cannot be recalled. Ask for an answer, not a transcript.- Any reply too long for one Telegram message (4096 chars, or
textChunkLimit) is split at a paragraph, line, or word boundary (chunkMode) and each part is prefixed(i/n), so it arrives complete and in order rather than cut off. Code fences are closed and reopened across the split. This covers assistant answers and command output alike — a long/sessionslisting or/cleanuppreview splits too, with the keyboard on the final part. - A part Telegram rate-limits (
429) is retried up to three times, honouringretry_after, instead of dropping the rest of the answer.
A host nobody is sitting at — a scheduler, a fleet orchestrator, anything whose
runs are cron ticks rather than someone typing — wants the opposite defaults from
a laptop. /telegram set profile daemon switches all of them at once:
- Output becomes explicit. Forces
streaming: explicit, so no live preview and no per-turn message. Onlytelegram_send/telegram_askreach the chat. This is what stops a multi-step answer arriving as five messages, and stops a message that lands mid-task from relaying the rest of that task's internal turns. - No idle post. The end-of-run notification is suppressed, so a tick's closing text stays on the host.
telegram_askis always mounted and aimed at the paired owner — including on a locally injected tick, which has no terminal to fall back to. Without this, a scheduled turn that needed a decision foundask(which nobody can answer) or no ask tool at all.
It is one key rather than three so a host cannot end up half-configured, and it
wins over a streaming value left in the file from before. /telegram status
reports the mode actually in force plus the profile. Approval and blocked-input
pings are unaffected: those mean something needs a human, which is the whole
point of the bridge on a host like this.
By default the bridge is reactive — it only messages a chat that messaged it first, and a locally-started run (one you kick off at the terminal) mirrors nothing. Away mode opts one destination into mirroring for local runs:
- Ask prompts — for any run started while away is on, when it calls
askthe question is shown on both this terminal and Telegram at once (single-select, multi-select, and Other free-text, exactly like a Telegram-originated turn). Answer wherever you are — the first surface to answer wins and the other closes. Turn away on, kick off your work, then walk away: its questions reach your phone, and the terminal picker still waits if you sit back down. It stays armed across runs while you're gone, but auto-clears the moment you type your next prompt at this terminal — so/awaythen walk away is the natural flow. (Usealwaysto keep mirroring even at the desk.) Flipping away on after a run has already started applies from the next run; that in-flight run'saskfalls back to a blocked ping (below). - Idle — a locally-started run finishes and the session goes idle: the bot
sends the run's final text, or
✅ omp idle in <dir> — your turn.when it had none. This is the one notificationset profile daemon(and anyexplicitstreaming mode) turns off: on a host whose runs are scheduled ticks it relays every run's closing prose, which is internal working text rather than anything addressed to you. Ask prompts and blocked pings still fire — those are "something needs you", not narration. - Blocked (fallback) — if a run parks on input that can't be mirrored (a
tool approval, or an
askthat started before away was on / has no answerable Telegram destination), the bot sends[BLOCKED] omp is waiting for your input in <dir>after a two-second grace — the state herdr shows asblocked. Answering at the terminal edits it to[ANSWERED].
Set it up:
-
/away— the quick toggle. Turns mirroring on when you step away and off when you're back; run it again to flip. This is all you need day to day. It auto-clears the next time you submit a prompt or command at this terminal — a phone reply never counts, so answering from your phone keeps it armed. (The./ccontinue shortcut and focused-subagent steering don't disarm it — run/awayif you need to.) -
/telegram notify <chat_id>picks the destination (grab your<chat_id>from/whoamiin the bot DM);/telegram notify cleardrops it. -
/telegram notify away | always | offis the full surface for the same modes:- away — mirror ask prompts and idle/blocked pings while you're gone;
auto-clears on your next interactive prompt at this terminal (a phone reply
doesn't count). This is the mode
/awaysets. - always — the same mirroring, but it never auto-clears — a standing "even at my desk, mirror to my phone" preference for juggling several herdr sessions you aren't actively watching. Turn it off explicitly.
- off — nothing mirrors; asks stay on this terminal.
- away — mirror ask prompts and idle/blocked pings while you're gone;
auto-clears on your next interactive prompt at this terminal (a phone reply
doesn't count). This is the mode
-
Only locally-started runs mirror — Telegram-initiated runs already stream their reply back, so they never double-notify.
-
With topics mode on, a session's prompts/pings land in its own topic; otherwise they go to the flat notify chat. Status and arming report the destination that will actually be used (topic first).
-
Sending doesn't need the poll lock, so in a multi-session setup any session with the token configured mirrors on its own — including the
<dir>so you can tell which one. -
Requires the bridge to be running — arming while it's off warns you, and mirroring only starts once you run
/telegram on.
A blocked ping is the fallback for input that can't be answered over Telegram; it fires past a two-second grace (a resolution before then cancels it):
- Tool approval —
[WAIT] omp is waiting for approval: <tool>. Telegram cannot approve or deny; finish it at the terminal. Resolving edits the message to[APPROVED]/[DENIED]. - Unmirrored
ask—[BLOCKED] omp is waiting for your input in <dir>with the question. This is what surfaces anaskthat wasn't turned into the dual-surface prompt — away wasn't on when the run started, or no answerable Telegram destination is configured. Answering at the terminal edits it to[ANSWERED]. When away is on at run start, the question is mirrored live instead (see Notifications) and no blocked ping is needed.
Session shutdown and agent completion clear pending timers.
When several omp sessions share one bot, a single chat can't tell them apart: the poll-lock holder receives every message and its replies land in the chat's main view, so you can't address a specific session. Topics mode gives each session its own Telegram forum topic, named after the pane herdr knows it by (falling back to its project directory), inside one operator-chosen chat.
/telegram topics on # topics on, auto-hosted in your paired DM; claim this session's topic
/telegram topics -1001234567890 # topics on, hosted in a forum supergroup
/telegram topics off # release this session's topic and turn topics off
/telegram topics # show the topics chat, this session's topic, and DM forum-topic mode
/telegram topics tidy on # tidy this session's topic on clean exit (delete in DM host, close in group host)
/telegram topics tidy off # keep topics after exit for re-adoption (default)
- Each session claims one topic on start (and when that session runs
/telegram topics onor/telegram topics <chat_id>). The title is the strongest identity available, in this order: the pane's herdr agent name (what you assigned withherdr agent start <name>oragent rename, one-to-one with the session), then the pane's herdr space label, thenbasename(cwd). The space is consulted because the agent lookup is best-effort — it reads herdr over a socket and returns nothing on failure — and becausetopics tidy onre-derives the title on every restart rather than once. On a host where several panes share one parent directory, that combination is what used to title every topic after the shared directory. Outside herdr,basename(cwd)is all there is. A session restarted in the same directory re-adopts its existing topic instead of creating a duplicate; a second live session in the same directory gets a<name>-<pid>topic. Sessions already running when topics are first enabled must reload or restart before they claim one. - Task subagents run inside their parent omp process and do not claim their own topics; their progress and final result stay in the parent session topic.
- A message typed inside a topic is routed to the session that owns it — even a different omp process — and that session's replies, streaming previews, and typing indicator all land inside that topic. Cross-process delivery goes through the shared state dir, so the lock holder forwards to siblings automatically.
- Messages in the chat's main view keep today's behavior (handled by the lock holder).
- A normal owner-DM message in a stale topic queues immediately and resumes the exact saved omp session in its original revalidated herdr space. Legacy topics or sessions created outside herdr need one local resume before that identity exists.
/stopis topic-local and reaches the owning session. Global commands such as/spawn,/sessions,/cleanup, and/statusare handled centrally by the poll-lock holder.- In an owner DM, the poll-lock holder creates one persistent omp control topic for global commands. It is reused across restarts and is not a session topic. Commands entered in another topic are redirected there.
Hosting in your DM (topics on). /telegram topics on needs no chat_id — a
DM's chat_id equals the paired owner's user id, so the bridge resolves the host
directly. Pair first with /telegram pair <code>. Historical state containing
multiple DM users fails closed until repaired locally. The bot must have
forum-topic mode enabled for its private chats — turn it on in @BotFather
→ your bot → Bot Settings. In the same settings, turn off "allow users to
create topics": the bot creates one topic per session itself, so if users may
create topics a command like /spawn typed outside an existing topic makes
Telegram spin up a throwaway topic to hold it. On
start the bridge reads getMe; if forum-topic mode is provably off it skips topic
creation, warns you to enable it in @BotFather, and runs untopiced rather than
failing cryptically. When user topic creation is on, /status and /telegram doctor
flag it. /telegram topics (bare) shows the current DM forum-topic mode
so you can see why topics do or don't work.
Hosting in a group (topics <chat_id>, the advanced path). Pass a negative
chat_id for a forum supergroup where the bot is an admin with the Manage Topics
right. Either way the topics chat must also be allowlisted (a paired DM, or a
configured group) like any other outbound target. If topic creation fails (mode off,
missing admin right), the session logs a warning and runs untopiced — the bridge
never blocks.
Topic lifecycle — persist by default, opt-in tidy. By default topics are not
closed or deleted when a session exits; they persist and are re-adopted on
restart in the same directory. Turn on tidy mode with /telegram topics tidy on
to clean each session's topic on a clean exit: in a DM host the topic is
deleted (the Bot API's only option there — this also removes its message history
and its stale-topic auto-resume identity), and in a forum supergroup it is
closed (parked, history kept) and reopened automatically when the next omp run
in that directory re-adopts it. A crash or kill -9 skips the clean-exit path; sweep
those leftovers with the owner /cleanup command — /cleanup previews the stale
topics with a confirm button (or /cleanup go) and then deletes (DM host) or closes
(group host) them, never touching live sessions or omp control. Either way, /spawn from Telegram always
starts a fresh session in a brand-new topic.
- Single operator: exactly one paired DM owns all Telegram control commands. Every message and inline-button callback revalidates both the sender ID and private chat ID. Callback controls expire after five minutes and are consumed before starting a process.
- DM-only control:
/spawn,/sessions,/cleanup,/stop,/compact,/model,/thinking, and/statusnever execute from groups. Group policies grant chat delivery only, not operator authority. - Configured groups are trusted prompt sources: an allowed group member still sends normal omp user turns with the session's workspace and tool access. Use sender allowlists; do not connect untrusted or public groups.
- Owner-only auto-resume: only a normal message from the paired owner's
private DM can resume a stale topic. The saved herdr workspace and terminal
identities are revalidated before
pane run; Telegram message text is queued as data and never interpolated into the shell command. - Outbound gate:
telegram_send/telegram_reactcan only target the paired owner or a configured group. The topics chat is no exception. - State-file guard: the bridge refuses to send its own
.env/access.json/ lock; onlyinbox/files under the state dir are sendable. - Bounded inbox: downloaded files are checked by actual byte length. Startup and download cleanup removes files older than 7 days, then prunes oldest files above 250 MiB total.
- No model-driven access changes: there is deliberately no tool to pair,
allow, or reconfigure access. Those happen only through
/telegramlocally. - Inbound messages are wrapped in
<telegram-message …>with attacker-controlled fields sanitized, and the first delivery reminds the model that Telegram messages are untrusted and must not drive access changes. - Bound prompt answers: selectable questions accept callbacks or Other text only from the originating responder in the same chat, topic, and Telegram message. Requests stay answerable while the owning session is alive instead of timing out, and survive poll-lock handoff; a request orphaned by a dead session is reaped.
- One poller per token: Telegram allows a single
getUpdatesconsumer per bot token. The standalone daemon and session fallback share a PID lock (bot.lock), preventing 409 conflicts during takeover.
- The standalone daemon requires owner-DM topics, an enabled bridge, and no configured groups. Other configurations use a live omp session as the poller.
- The daemon runs only while the host is awake and logged in; it is not a launchd/systemd service.
/spawn,/sessions, and stale-topic auto-resume require herdr.- Sessions already running when topics are first enabled must reload or restart before they claim a Telegram topic.
- Owner-DM topics record the exact current session and herdr space for auto-resume. Legacy topics and sessions started outside herdr must be resumed locally once before this metadata exists.
- Telegram creates a user-started topic before the bot receives its first message. Use omp control instead of the new-chat composer for commands.
- Exactly one private-DM operator is supported. Configured groups can chat with omp but never receive control authority.
- The Bot API exposes incoming updates, not searchable Telegram history.
- Native
/new,/plan, and/goalare not relayed: omp does not expose their TUI-only session/mode actions to asynchronous extensions./spawnstarts a separate session. Tool-approval waits can ping Telegram, but approval and plan approval remain terminal-local.
- While a Telegram chat is active (its message injected, awaiting the agent), the assistant's output mirrors to that chat. If a Telegram message arrives mid-way through an unrelated local task, that task's output also streams to the chat until it finishes — the reply is never dropped. Pure local sessions (no Telegram message in flight) never mirror anything.
- Telegram's Bot API has no history or search: the bot only sees messages as they arrive.
bun install
bun run typecheck
bun test
# or both:
bun run check