Telegram Bridge for DeepSeek Harness
Talk to DeepSeek Harness (DSH) from Telegram. The bridge runs a bot inside the DSH Host process and lets you:
- See every session — titles, working directories, running/idle state, last activity.
- Read results — the latest assistant answer, per-turn, with token usage.
- Review a run — the request, the answer, every tool call (ok/failed with a
result preview), and the files it changed with
+/-counts. - Keep the agent working — send plain text as a prompt, steer, stop a run, create or rename sessions.
- Talk instead of typing — Telegram voice notes are transcribed locally by DSH's voice-input model and sent as prompts.
- Review media deliverables — videos, audio, images and documents a turn
produced are sent to Telegram automatically;
/filesresends the last turn's. - Answer the human-in-the-loop moments — approve/deny tool calls and answer
ask_user_questioncards from your phone when the desktop app is not in front of you. - Get pushed the moment a turn ends — with buttons for Result / Review / Status.
No inbound port, no webhook, no public URL: the plugin long-polls the Telegram Bot API from the Host.
Setup
1. Create a bot and get a token
- Open Telegram, talk to @BotFather, send
/newbot. - Choose a name and a username (
…_bot). - Copy the token (
123456789:AA…).
2. Give the token to DSH
Pick one of these (checked in this order):
| Method | How |
|---|---|
| Configuration | botToken: in the telegram-bridge row of the profile patch (not recommended for shared machines). |
| Credential reference (recommended) | Set a TELEGRAM_BOT_TOKEN environment variable for the user that launches DSH, or store it through the Harness credential store. The row default is tokenRef: TELEGRAM_BOT_TOKEN. |
For a Windows desktop install: System Properties → Environment Variables →
User variables → New, name TELEGRAM_BOT_TOKEN, value = the token; then fully
restart DeepSeek Harness (closing the window is not enough — quit from the tray
menu). The bridge reads the credential on startup.
3. Authorize your chat
After the restart, send
/startto your bot. Because no chat is authorized yet, the bot enters pairing mode and replies with your numeric chat id and user id — nothing else is executed.Add your chat id to the plugin row in the profile patch (
%USERPROFILE%\.dsh\profiles\desktop\cordis.patch.ymlon Windows,~/.dsh/profiles/desktop/cordis.patch.ymlelsewhere), not the bundle's own patch:- id: telegram-bridge name: "@local/dsh-telegram-bridge" config: tokenRef: TELEGRAM_BOT_TOKEN allowedChatIds: ["123456789"](
allowedUserIdsworks too for private chats; the two lists are merged.) A row override replaces the wholeconfig, so restatetokenRefwhen you setallowedChatIds. The profile patch is reapplied after bundle layers, so it survives plugin upgrades.Restart DSH. Send
/help— you should get the command card.
The bundle intentionally ships no generated settings form: it is a zero-dependency plugin that validates its own config and reports invalid values as warnings in the Host log. Configure it through the profile patch (or the bundle's
cordis.patch.ymlif you are developing it).
Pairing never authorizes: an unknown chat only learns its own id, and the notice is rate-limited. Until the id is in the allowlist, every command is ignored.
Commands
Plain text (anything that does not start with /) is sent as a prompt to the
selected session — the fastest way to keep an agent going from your phone.
Child-agent (subagent) and archived sessions are hidden from /sessions
by default. Set sessions.includeSubagents: true and/or
sessions.includeArchived: true to list them, or address one directly with
/use <full session id>.
| Command | What it does |
|---|---|
/sessions (or /list) |
List sessions; tap a button to select one. |
/use <n | id-prefix | title> |
Select a session by list number, id prefix, or title substring. |
/status (/current) |
State, turn/steps, model, tokens, context, policy, activity. |
/result [turns] |
Latest assistant answer(s), default 1 turn. |
/files |
Resend the last turn's produced media/documents to this chat. |
/review |
Request / answer / tool calls / changed files for the latest turn. |
/tail [turns] |
Compact recent-turn overview. |
/controls (/more) |
Every session action as buttons — same card as the ⚙️ All controls button. |
/say <text> |
Send a prompt (explicit form of plain text). With no text, waits for your next message. |
/steer <text> |
Send a steering message to the running turn. With no text, waits for your next message. |
/stop |
Stop the running turn. |
/new [absolute-cwd] |
Create a session (optional working directory) and select it. |
/topic [rename <name> | sync | close | reopen] |
Create/bind, rename, resync, close, or reopen the session's topic. |
/rename <title> |
Rename the selected session. With no title, waits for your next message. |
/pending |
List pending approvals/questions with action buttons. |
/approve [id] / /deny [id] |
Decide the oldest (or a specific) pending approval. |
/notify on|off |
Toggle push notifications for this chat. |
/notify all|current |
Notify for every session, or only the selected one. |
/whoami |
Your ids, selected session, notification state, web UI URL. |
/debug |
Redacted bridge diagnostics for troubleshooting. |
/cancel |
Abandon a pending button text prompt (Say…/Steer…/Rename…) or typed answer. |
/help |
Command card. |
Session buttons
The session card (/status, after selecting a session, and on push
notifications) has Result / Review / Sessions / Stop / Refresh / Notifications
plus ⚙️ All controls. That opens one card with every action:
- Status, Result, Review, Tail — read the session.
- 📎 Files — resend the last turn's media/documents.
- Say…, Steer…, Rename… — the bot asks for the text as your next message
(send
/cancelto abort). - New, Stop, Sessions, Pending, Help — session and app actions.
- Notify all / selected / off — switch push scope in one tap.
- Whoami, Debug, Refresh — diagnostics and re-render.
Approval and question messages carry inline buttons; questions with multiple selectable answers also get a ✅ Done button and an ✏️ Other button that switches the conversation into "type your answer" mode for that question.
Routing a reply to the right session
Selecting a session makes it the default target for plain text and commands. When notifications from several sessions arrive at once, you do not have to reselect:
- Reply to the message (Telegram's quote/reply gesture) — the prompt goes to the session that message belongs to, and that session becomes active for follow-ups. This works for turn notifications, status cards, Result/Review/ Tail output, and the All-controls card.
- Tap Result / Review / Status on a notification — the button already targets that session and selects it.
- Commands (
/status,/result, …) still act on the currently active session; reply-routing applies to plain text (and voice notes, once transcribed).
The mapping is remembered in memory (24 h, up to 300 messages per chat). After a
restart, replies to older messages fall back to the active session, so send
/sessions or tap the notification's buttons to be explicit.
Telling the model the user is remote
A DSH session normally belongs to someone sitting in front of the desktop app, so the agent will happily open files/windows there unless told otherwise. The bridge makes the transport explicit:
- Every Telegram-originated prompt (typed text, reply-routed text, and
transcribed voice) is prefixed with
[via Telegram]. - On activation the plugin registers a Host system-prompt section explaining
that marker: the sender is remote, they cannot see this computer, and
file/review requests must be delivered with the
presenttool (the bridge forwards declared files to the chat) rather than opening files, folders, or applications on the host. It also explains the ~45 MB forwarding limit and that only the final answer and presented files reach the phone.
The marker stays in the session log next to the request (visible if you later read that session in the desktop app), which keeps the origin durable across resume and forks. Prompts typed directly in the DSH app have no marker.
Forum topics: one topic per session
In a forum supergroup the bridge can bind one topic to one Session, so the chat itself carries the routing:
- General is a doorway:
/sessionsand/newredirect to a dedicated control topic (topics.controlName, default DSH). It is created on first use (with a pinned card), and it is never bound to a session. - Selecting a Session in the control topic (button or
/use) creates or reuses its own topic and posts the session card there. Bound sessions are marked 🧵 in the list and its buttons; selecting one again reuses that topic (the bot says so) instead of creating a duplicate. - A Session never selected from the chat still gets its topic automatically on
its first turn notification, approval, or question
(
topics.autoCreateOnNotify). - The
/sessionscard in a forum also shows a 🧵 Create topics button that creates and binds one topic for every session on the visible page in one tap. - Inside a session topic, plain text and voice notes prompt that topic's
Session;
/status,/result,/review,/files,/stop,/renameall default to it. Running/useinside a topic re-binds that topic. - Turn notifications, approvals, and questions for a bound Session are posted
into its topic. The status card is pinned (
topics.pinCard, needs the bot's pin permission) and replaced when the session is selected again. /topiccreates the current Session's topic on demand;/topic rename <name>overrides its name,/topic syncrestores the title-derived name, and/topic close//topic reopentoggle it manually. Archiving a Session closes its topic; restoring reopens it (topics.syncArchive).- Topic names derive from the Session title (optionally prefixed) and are
truncated to
topics.titleChars(default 48)./renameupdates the topic name too, so a renamed session keeps a matching topic.
Bindings persist in the state file (<chatId>:<message_thread_id> → session),
so they survive restarts. Setup requirements:
- The group must be a supergroup with Topics enabled.
- The bot must be an administrator with the Manage Topics right (Telegram → group → Administrators → your bot).
- Bot privacy mode must be disabled (BotFather →
/setprivacy→ Disable), otherwise the bot only receives commands, mentions, and replies in groups; plain prompts in topics would never arrive. - Add the supergroup's chat id (starts with
-100…) toallowedChatIds. Converting a group to a supergroup changes its id. - Allowlisting a group authorizes every member of that group; keep it private and use a user allowlist where membership is not controlled.
How interactions behave
Two independent settings decide who gets a request first:
| Mode | Behavior |
|---|---|
auto (default) |
If a browser/desktop client is connected, it answers in-app and Telegram only gets a notice. With no connected client, the bridge claims the request and waits for your tap. |
telegram |
Always claim in Telegram (useful when the app is open in a background tab). |
notify |
Never claim; only send a notice. |
off |
Ignore the request entirely. |
Settings: approvals.mode / approvals.timeoutMs (default 120 s) and
questions.mode / questions.timeoutMs (default 300 s). A claim that times out
is delegated back to the remaining answerers, so DSH never hangs on a phone
that went offline. A claimed approval is answered exactly once; its message is
edited to show the final decision and its keyboard is removed.
Notifications
- On
turn/endfor a watched session: one compact header message (status, duration, token usage, session id + Open DSH link), then the complete final assistant message. Telegram caps a message at ~4K characters, so long answers arrive as several consecutive messages, each a valid<pre>block; the Result / Review / Status buttons ride on the last one. Setnotifications.snippetCharsto a positive number (e.g.700) if you prefer a truncated push. There is no separate footer bubble. - On
agent/errorwhen turn-end pushes are disabled: the failure message. - On approvals/questions that stay in the desktop UI: a notice.
- Sessions that are their own child agents (
origin: subagent) are skipped unlessnotifications.includeSubagents: true.
Origin-aware pushes. Every Telegram prompt starts with [via Telegram], so
the bridge can tell where the current turn came from. With
notifications.onlyTelegramOriginated: true (the default):
- A turn whose latest prompt came from Telegram pushes its header, answer, media, and interactions to the chat.
- A turn started in the desktop app stays quiet on Telegram — no turn notification, no media, no duplicate interaction notices. Answer it in DSH.
- Ambiguous/unknown turns are treated as desktop (quiet) rather than noisy.
Set it to false to get pushes for desktop-originated turns too (the old
behavior). Interaction claims are still controlled by approvals.mode /
questions.mode; in auto mode a connected desktop client answers in-app.
A chat watches its selected session by default; /notify all switches it to
every top-level session.
Voice notes
Send a Telegram voice note (or an audio file / round video note) and the bridge:
- downloads it through the Bot API,
- converts it with ffmpeg to 16 kHz mono PCM16 WAV,
- transcribes it with DSH's
speechToTextservice (local SenseVoice model by default, or any recognizer configured in DSH Settings → Voice input), - echoes 🎤 Heard: “…” and sends the transcript as a prompt to the selected session — or uses it as the answer to a pending question / armed button action.
Requirements:
- The Voice Input bundle enabled in DSH (
@deepseek-ai/dsh-experimental-voice-input-bundle, enabled on this profile) and its recognizer prepared. - ffmpeg on the Host
PATH(or setvoice.ffmpegPath). - The first transcription downloads the local model once (about 239 MB int8,
from huggingface.co or hf-mirror.com, checksum-verified) and caches it under
$DSH_HOME/speech-to-text/. The chat shows download progress and can wait up tovoice.prepareTimeoutMs.
Voice transcription errors are reported in the chat: missing bundle, missing ffmpeg, unrecognizable speech, or a failed model download. The audio itself is deleted from temp storage after each attempt; only the transcript becomes a session message.
File deliverables
When a turn finishes, files it produced are forwarded to the same chats that get the turn notification:
presentdeliverables — files the agent declared with DSH'spresenttool (the delivery cards in the app). These are forwarded regardless of the extension allow-list; their description becomes part of the caption.- model-attached images/files from the final assistant message (when the attachments provider exposes a Host path), and
- workspace files changed by the turn whose extension marks them as
reviewable: images (
png jpg jpeg webp gif), video (mp4 webm mov m4v mkv), audio (mp3 wav m4a ogg flac aac), documents (pdf docx pptx xlsx csv zip).
Images arrive as photos with inline preview; a photo over 9 MB is sent as a
document instead. Videos/audio get inline players. When a video or audio file
exceeds deliverables.maxBytes (default 45 MB, under Telegram's ~50 MB bot
upload cap), the bridge transcodes a compressed copy with ffmpeg and sends
that (1080p → 720p → 480p renditions, first copy that fits). The caption says
"compressed from …" and the original file on the PC is untouched. If ffmpeg
cannot produce a fitting copy (or compression is disabled), the file is skipped
and reported in a short "not forwarded" note instead of failing the
notification. Code-only changes are never uploaded. /files (or the 📎 Files
button under All controls) resends the previous turn's deliverables on demand.
Set deliverables.enabled: false to disable forwarding, or replace the
allow-list:
deliverables:
enabled: true
maxBytes: 47185920
extensions: ["mp4", "png", "pdf", "epub"] # or {epub: document, ...}
compression:
enabled: true # transcode oversized video/audio with ffmpeg
timeoutMs: 600000 # per rendition
Configuration reference
All fields are optional. They live in the telegram-bridge row's config
(profile patch or bundle patch). Invalid values fall back to the default and are
reported as warnings in the Host log.
- id: telegram-bridge
name: "@local/dsh-telegram-bridge"
config:
tokenRef: TELEGRAM_BOT_TOKEN # credential reference; default TELEGRAM_BOT_TOKEN
botToken: null # direct token override (avoid in shared machines)
apiBaseUrl: https://api.telegram.org # local Bot API server / tests
allowedChatIds: [] # numeric chat ids; empty = pairing mode
allowedUserIds: [] # user ids allowed in any chat
dropPendingUpdates: true # ignore backlog on a fresh start
statePath: null # advanced: override the chat-state file
poll:
timeoutSeconds: 25
backoffMinMs: 1000
backoffMaxMs: 60000
notifications:
enabled: true
mode: current # default scope for new chats (current | all)
includeSubagents: false
onTurnEnd: true
onError: true
onApproval: true
onQuestion: true
onSessionStart: false
snippetChars: 0 # 0 = send the whole final message; >0 truncates
onlyTelegramOriginated: true # desktop turns stay quiet on Telegram
approvals:
mode: auto # auto | telegram | notify | off
timeoutMs: 120000
questions:
mode: auto # auto | telegram | notify | off
timeoutMs: 300000
limits:
pageSize: 8 # sessions per page
maxResultTurns: 5
maxTailTurns: 20
maxEventsPerRead: 1500
sessionTitleChars: 48
sessions:
includeSubagents: false # hide child-agent sessions from /sessions
includeArchived: false # hide archived sessions from /sessions
topics:
enabled: true # forum supergroup: one topic per session
autoCreate: true # open a topic when a session is selected in General
autoCreateOnNotify: true # give a session its topic on first notification too
syncArchive: true # close/reopen a topic when its session is archived
pinCard: true # pin the session card in its topic
controlName: DSH # dedicated control topic for /sessions and /new
titlePrefix: '' # optional prefix for created topic titles
titleChars: 48 # truncate the session title in topic names
pairing:
notice: true
cooldownMs: 600000
web:
includeUrl: true
voice:
enabled: true
language: null # null = recognizer auto (en, zh, ja, ko, …)
autoSend: true # send transcript as a prompt automatically
maxDurationSeconds: 300
maxDownloadBytes: 20971520
maxWavBytes: 4194304 # recognizer input cap (≈131 s of 16 kHz PCM16)
prepareTimeoutMs: 900000 # first-use model download window
totalTimeoutMs: 1200000
ffmpegPath: null # default: ffmpeg from PATH
deliverables:
enabled: true # forward produced media/documents to Telegram
maxBytes: 47185920 # Bot API upload ceiling headroom
extensions: [] # [] = built-in allow-list; or [mp4, png, …]
compression:
enabled: true # transcode oversized video/audio (ffmpeg)
timeoutMs: 600000
The state file defaults to $DSH_HOME/telegram-bridge/state.json and stores only
preferences: the update offset and each chat's selected session / notification
settings. Pending approvals and button-to-session caches are memory-only, so a
button older than a restart fails with "list expired" instead of acting on the
wrong row.
Security model
- Allowlist first. Unauthorized chats can only learn their own ids.
- No inbound listener. Long polling means nothing is exposed on your network.
- Authority matches DSH. Claiming an approval or question only when
auto-unattended (or explicitly configured) is deliberate: granting tool authority to a chat is a policy decision, and the timeout delegates back to the in-app answerer. - Tokens never leave the Host. They are resolved per startup, never logged, never rendered into a message, and redacted from diagnostics.
- Everything is escaped. User/model text is HTML-escaped and chunked into independently valid messages, so malformed model output cannot break delivery or inject markup.
- Callback data is short and scoped. Session ids in buttons are validated against live sessions before use.
Architecture
Telegram ⇄ Telegram Bot API (long poll) ⇄ Host plugin (index.js)
├─ commands.js update router + commands
├─ interactions.js approvals / questions
├─ views.js Telegram HTML rendering
├─ bridge.js DSH session-controller access
├─ telegram-api.js Bot API client (retry, rate limit, chunking)
├─ state.js durable per-chat preferences
└─ config.js validation + token resolution
Data comes from the Host's own services: sessionController (list, inspect,
prompt, cancel, create, rename, projections), sessionQuery (titles),
workspaceChanges (per-turn changed files), typertGateway (desktop-client
presence), webApp (GUI URL), and credentials (token reference). Nothing is
scraped from the filesystem and no session events are written.
Development
cd dsh-telegram-bridge
node --test # unit + end-to-end tests (no network)
Tests use an in-process fake Host context and a fake Bot API on 127.0.0.1, so
they never touch your real bot, profile, or session logs. scripts/live-fake-telegram.mjs
is a standalone fake Bot API for manually pointing a real DSH Host at a local
endpoint during development (node scripts/live-fake-telegram.mjs <out-dir> 18765).
Troubleshooting
| Symptom | Fix |
|---|---|
| Bot never answers | Check that TELEGRAM_BOT_TOKEN is visible to the DSH process (set it, then fully quit and relaunch DSH), and that the telegram-bridge row is active in Settings → Plugins. /debug works once the bot answers. |
| Bot replies "Pairing required" | Add the printed chat id under allowedChatIds in the profile patch, then restart. |
| Plugin row is active but idle | No token was resolved. The Host log has no bot token found (ref TELEGRAM_BOT_TOKEN). |
409 Conflict warnings |
Another DSH process (or another copy of the app) is polling the same bot. Only one long poll per token is allowed. |
| Approvals never show buttons | A desktop client is connected, so auto mode defers to the in-app answerer. Answer there, or set approvals.mode: telegram. |
| Buttons answer "list expired" | The session list is memory-only; send /sessions again after a restart. |
| Voice note: "Voice input is not available" | Enable the Voice Input bundle (Settings → Plugins → Voice Input) and restart DSH. |
| Voice note stuck on "Downloading voice model…" | First use downloads ~239 MB. Let it finish or check the network; verified files are cached, so a retry continues from the cache. |
| Voice note: "ffmpeg was not found" | Install ffmpeg on the Host PATH, or set voice.ffmpegPath to its absolute path, then restart DSH. |
| Voice transcript is wrong/empty | Speak a bit longer and closer to the mic; pick a matching voice.language; very short clips may be rejected as empty. |
Limitations
- Text messages and voice notes inbound; outbound media is limited to the
Bot API's ~50 MB upload ceiling and the
deliverablesallow-list. Oversized video/audio is transcoded when ffmpeg is available; other oversized files are skipped with a note. - Inbound photos, documents and stickers are not read yet.
- One bot token per installed profile;
apiBaseUrlsupports a self-hosted Bot API server. - Telegram messages are edited best-effort: very old approval/question messages may keep a stale keyboard, but a late tap is rejected as expired.
- Only long polling, not webhooks.
- Deep links into the desktop app are not generated;
/whoamishows the GUI URL.
Development & source
- Repository: CaptainPersia-66/dsh-telegram-bridge (public, MIT).
- Run the suite with
node --test— no dependencies beyond Node 24; ffmpeg is optional (voice notes and large-file compression). - Every completed, verified change is checkpointed to
mainand milestones are tagged (v1.0.0is the first). The plugin stays installed from this folder, so commits do not disturb the running profile. - Working rules for agents live in AGENTS.md.
No comments yet. Be the first to write one.