dsh-telegram
August 24, 2026 ยท View on GitHub
Recreate the web-app control experience for DeepSeek Harness agents on Telegram.
๐ค Chat with dsh agents from a phone ยท ๐๏ธ Drive sessions/models/presets/workspaces with buttons ยท ๐ง Live status & queue counts ยท ๐ก๏ธ Multi-chat isolation and fail-closed routing
English | ็ฎไฝไธญๆ
Why โข Features โข Quick Start โข Configuration โข Architecture โข Commands โข How It Works โข Tests โข Docs โข Safety
Why?
DeepSeek Harness's web UI is the gold standard for controlling an agent: sessions, models, workspaces, goals, presets, approvals. This plugin brings that surface to Telegram with the messaging ergonomics humans actually expect from a phone bot:
| Dimension | Typical Telegram bot | dsh-telegram |
|---|---|---|
| ๐ค Chat | One global session, implicit state | One agent per chat, bound sessions, fail-closed routing |
| ๐งญ Navigation | Long slash-command lists | Persistent reply-keyboard bar + paginated inline cards |
| ๐งฉ Models | Text config or guesswork | Provider cards, 12/page paging, per-session reasoning picker |
| ๐ฌ Queue | Invisible inbox | Live โ Queue ยท N count embedded in the bar key |
| ๐ก๏ธ Approval | No path on mobile | Inline Allow/Reject cards settled in place (buttons removed) |
| ๐ Attachments | Dropped silently | Photos enter the session; documents/voice/video get clear guidance |
| ๐ฌ Replies | Plain messages | Native reply-quote to the triggering message, clean reply surface |
| ๐ Streaming | Everything at once | Harness-native live feed: Think lane, presenter-driven tool rows, and a typewriter answer that streams as it generates |
Features
- ๐ Multi-chat isolation โ per-chat agent bindings, per-chat FIFO inbound router that spans the whole createโbindโdeliver path (two rapid first messages can never create two sessions), unbound chats fail closed for display too
- โก Responsive UI lane โ bar buttons and inline callbacks now also ride a dedicated
control:<chat>outbound queue, so Goal/Todos/Queue/ๆถ่ตท react instantly while assistant streaming occupies the content queue - ๐ Context-pressure compaction (#8) โ
compact.threshold/compact.policy/compact.cooldownMstrigger an approval card or auto compaction near the model window, then announce the summary and shadowed tokens - ๐ Media (#9) โ multi-photo media groups become one user turn, voice transcribes via
media.transcribe.*, documents/videos land in the session attachments directory - ๐ Todos (#10) โ
/todo, a๐ Todos ยท Nbar entry, and incremental todo cards from durabletodo/writeevents - โก Responsive UI lane โ bar buttons and inline callbacks (collapse bar, Goal, menu navigation, question cards) run immediately instead of waiting behind a slow inbound turn; session creation stays serialized per chat across lanes
- ๐๏ธ Button-first UX โ persistent reply bar (
โฐ Menu ยท โจ New ยท ๐งฉ Modelsโฆ) plus ephemeral inline cards for sessions, workspaces, goals, skills, subagents, presets, settings, credentials, llm/models, host, jobs, plugins and dynamic inventory - ๐๏ธ Project-grouped sessions โ the Sessions card mirrors the web display-title chain (
session/titleโ cwd basename โ id), groups sessions by workspace project, opens the running project first, offers a๐ ้กน็ฎswitcher, per-row Chineseๅฝๆกฃ/ๅ ้คactions, and hides archived sessions with a live๐Ncount - ๐ก Bar control โ Menu page 1 has a
๐ก ๆถ่ตท Bar / ๆพ็คบ Barswitch and/bar [on|off]toggles the keyboard; the bar's๐๏ธ ๆถ่ตทhides it without leaving any carrier message behind - ๐ฏ Goal in menu โ Goal lives in the first menu page (shares a row with Capabilities); the card is display/edit/pause/
๐ Clear goal(or/goalclear) only and never disturbs the running session;/goal <objective> [maxRounds]starts a goal, long goal turns get a step/tool progress card that collapses into the compact receipt with the cache hit rate - ๐๏ธ Workspaces are usable โ a Workspace detail card has
โ ไฝฟ็จๆญค้กน็ฎ(set active project for new sessions) and๐งญ ไผ่ฏ(open its sessions) - ๐ Web-parity surface โ adapters mirror the web ApiProxy RPC contract:
session.list/search/create/history/models/selectModel/prompt/attachment/updateQueue/cancel, subagents, host, workspace, agent presets, skills, goals, settings, credentials, llm providers/discovery - โก Harness-native live feed โ reuses DeepSeek Harness's own output pipeline end to end: the same
session/eventstream, web-chat assistant block folding (assistant/chunkโ text/reasoning blocks), and tool presenters (ctx.toolspresentCall/presentResult) render a Think line (latest while running, first when frozen), compact tool rows with real result signals (exit codes / match counts / source counts), and a typewriter answer bubble that streams live and settles in place at turn end โ plus the turn summary with thoughts/tools/duration, tokens and cache hit rate (outbound.liveFeed, hot-toggleable) - ๐ HTML-aware long sends โ messages over 4096 chars are split on newline/space boundaries, never inside tags or entities, with tags rebalanced per part
- โป๏ธ Reliability-first queue โ per-chat FIFO + global sliding-window rate limit; retries only 429/5xx/network/timeout, permanent 4xx fails once; restart-safe long polling with offset preservation
- ๐ Hot update & hot plug โ
internal/updatelive-applies whitelist/rules/rate/length/watch without restart; teardown reverses every mount effect and re-apply is idempotent - ๐ก๏ธ Safe by default โ chat allowlist (
empty = deny all), agent tools restricted to the roster, callback payloads percent-encoded, callback tokens single-use and bounded, secrets never ride back - ๐ค Agent tools โ
telegram_send/telegram_reply/telegram_broadcast/telegram_attach(files: photo/voice/audio/document) /telegram_status/telegram_mark_no_reply, all routed through the same audited send pipeline
Quick Start
1. Create a Telegram bot
Open @BotFather, send /newbot, and keep the returned token.
2. Install the plugin
The package ships its own bundle patch (dsh.bundle.patch โ cordis.patch.yml), so the official CLI installs and mounts it in one step โ no profile file edits:
# from a local checkout (link: keeps the profile pointed at your working tree)
dsh plugin --profile <name> add link:/path/to/dsh-telegram
# or from npm once published
dsh plugin --profile <name> add dsh-telegram
3. Provide the token
Resolution order at mount time โ first hit wins, values are never logged or persisted by the plugin:
TELEGRAM_BOT_TOKENenvironment variable (recommended);TELEGRAM_BOT_TOKEN_FILEโ path to a file whose whole contents are the token;.telegram-tokenbeside the package (gitignored checkout-local fallback): write the token into<package>/.telegram-token, keep it0600.
export TELEGRAM_BOT_TOKEN='123456:ABC...' # option 1
# or: printf '%s\n' '123456:ABC...' > /path/to/dsh-telegram/.telegram-token && chmod 600 $_
3. Configure telegram.json
At <workspace>/.pi/telegram.json (the nearest ancestor directory containing .pi):
{
"security": { "allowedChatIds": [123456789] },
"watch": { "autoStart": true },
"outbound": { "liveFeed": true },
"interactive": { "userQuestions": "both" }
}
All fields are optional; security.allowedChatIds empty means deny all inbound traffic.
4. Start and allow
/telegram start # begin long polling (or rely on watch.autoStart)
/telegram allow <id> # whitelist your chat id
Then send /start to the bot in Telegram. An unauthorized chat that sends /start first gets an Allow button โ after tapping it, the welcome message is replayed automatically.
5. Chat
Send a message. The bot binds the chat to its own dsh session, streams the turn (when outbound.liveFeed is on), and replies as a native Telegram quote; no feedback keyboard is attached to the reply.
Configuration
| Field | Default | Description |
|---|---|---|
security.allowedChatIds | [] | Inbound whitelist; empty denies all inbound traffic |
watch.autoStart | false | Start long polling when an agent is created |
inbound.defaultMode | auto-handle | auto-handle / queue-only / muted |
inbound.rules | [] | Ordered rules on chatId and/or case-insensitive pattern |
outbound.parseMode | HTML | Telegram parse mode for assistant replies. Model Markdown (bold/italic/code/links/lists/headings/quotes) is normalized to valid HTML automatically; internal cards are always HTML |
outbound.disableNotification | false | Send silently |
outbound.maxRetries | 3 | Retry attempts for transient failures only |
outbound.sendRatePerSecond | 20 | Global sliding-window rate limit |
outbound.maxMessageLength | 4096 | Telegram HTML message limit, used by the splitter |
outbound.liveFeed | true | Harness-native streaming feed (built-in; the legacy loader entry keeps working as an alias) |
workspace.activePath | โ | Active project folder for new sessions |
mode.name | โ | Profile mode label |
model.provider / model.model | โ | Telegram-owned default model, inherited by /new and โจ New |
reasoning.effort | medium | minimal / low / medium / high / max directive prefix |
interactive.userQuestions | both | ask_user_question ownership: telegram / web / both / auto (both is the default). both shows approval/question asks on Telegram and the web when the host web api-proxy is mounted; answering on either side settles the other automatically, and if api-proxy is unavailable it falls back to Telegram-only. telegram keeps working in the web profile even when the API proxy owns the user-questions provider seam; web yields to the browser UI; auto keeps the legacy loader-entry inference |
interactive.allowByTool | [] | Tool names permanently auto-allowed after the user taps Allow forever (by tool) on an approval card (e.g. ["bash", "web_search"]); set to [] to revoke all |
Live updates: Telegram-side /config get|set <path> [json] or dsh-side /telegram config get|set <path> <json> hot-apply and persist any leaf (e.g. outbound.sendRatePerSecond). interactive.userQuestions is read at plugin mount and applies on the next restart; interactive.allowByTool hot-applies immediately.
Architecture
Telegram โ grammY long polling โ per-chat FIFO router
โ
โโ Bridge per-chat bindings, inbound quoting, turn events, reminders
โโ Transport send queue, rate limit, retry classification, HTML split, stop/start generations
โโ Cards ephemeral menu/session/model/workspace/goal/... keyboards
โโ Interactive approval/question cards (settle in place)
โโ Adapters web ApiProxy parity over ctx services
โโ Extensions reasoning directive + harness-feed streaming renderer
| Layer | Files | Responsibility |
|---|---|---|
| bridge | src/harness/bridge.ts | Per-chat agent routing, event fan-in, native reply quoting, live-feed gate |
| transport | src/telegram/transport.ts | Long polling, send queue, timeouts, photo/document delivery |
| queue | src/telegram/queue.ts | Per-chat FIFO + global sliding window + transient-only retries |
| router | src/telegram/router.ts | Per-chat FIFO for commands/bar/text/callback/media, unauthorized gating |
| html | src/telegram/html.ts | Escaping helpers + HTML-aware long-message splitter |
| keyboard | src/telegram/keyboard.ts | Pure builders for bar/menu/cards, encoded callback payloads |
| tokens | src/telegram/tokens.ts | Bounded single-use callback token registry |
| adapters | src/harness/adapters/ | sessions, workspaces, goals, skills, subagents, presets, settings, credentials, llm, host, jobs, plugins, status |
| extensions | src/extensions/ | reasoning (effort directives) and harness-feed (streaming renderer; openclaw is a deprecated alias) |
| entry | src/index.ts | apply/teardown, dsh commands + agent tools, card dispatch, hot config |
Commands
dsh side
/telegram status ยท /telegram start ยท /telegram stop ยท /telegram allow <chatId> ยท /telegram disallow <chatId> ยท /telegram watch on|off ยท /telegram config auto-start ยท /telegram config get|set <path> [json]
Telegram side
/start /menu /new /compact /stop /models /sessions /workspaces /project [path] /goals /todo /bar [on|off] /skills /subagents /presets /plugins /hostsettings /credentials /host /jobs /status /help /menucheck /answer /config get|set <path> [json]
Plus /history [id] [turns] (the web's turn-grouped trajectory ledger: per-turn model, outcome, duration, and ๐ค/๐ง /๐ง/๐ฅ/๐ค steps), /rename <title>, /fork [atSeq], /use <id>, /archive <id>, /queue, /todo, /steer <text>, /cancel, /goal <objective> [maxRounds], /goalcreate <objective> [maxRounds], /goaledit <text>, /goalclear, /workspacecreate <path> [title], /workspacepin <workspaceId> <sessionId> [before], /pluginenable|plugindisable <name>, /settingsdescribe [ns], /settingsupdate <ns> <json>, /settingsreplace <ns> <json>, /settingsmutate <ns> <json-ops>, /credential|credentialset|credentialunset <REF> [value], /ls [path], /mkdir <path>, /openpath [path], /pickdir [path], /discover <settingsNs> [baseURL], /subagentprompt <text>, /sessionlog [id], /commands, /capabilities, and /pluginadd [json] (install your own dynamic plugin from the phone: the host half can call your own model to decode; run/stop/remove live on the Dynamic card, #50).
How It Works
A turn's full lifecycle:
- Telegram delivers an update โ transport answers callbacks first, then the per-chat FIFO router dispatches command / bar button / callback / text / photo / document.
- The first message in a chat creates and binds a chat-owned dsh session; the FIFO promise spans the whole create โ bind โ deliver path, so a burst of first messages still lands in one session.
- The bridge delivers the message as a user turn (or queues it per inbound mode) and records the Telegram message id for native reply quoting.
- Whenever
outbound.liveFeedis on, the built-in harness-native feed streams the turn the same way the web UI does: Think + tool rows on one activity card, prose typing into its own quoted answer bubble that settles in place (a loader-mounteddsh-telegram/extensions/openclawentry is still honored as an alias โ the singleton guard prevents double mounting). - The final assistant text lands as a native reply to the triggering message with no feedback keyboard attached; a missing
telegram_replyproduces an explicit reminder instead of silence. - Approval/question cards are claimed by the bridge, answered inline, and settle by editing the card in place (buttons removed).
Tests
npm run check # tsc build + node --test: 244/244 green
npm audit --omit=dev # 0 vulnerabilities
npm pack --dry-run # publish payload: dist + README + README.zh + CHANGELOG + LICENSE
The suite covers bridge routing, multi-chat isolation, transport races, queue retry classification, HTML splitting, keyboard payload encoding, token single-use, interactive cards, config hot-update, every web-parity adapter, and apply-level integration tests (including the rapid-first-message race).
Docs
docs/WEB_PARITY_AUDIT.md: per-method web-parity status and remaining gapsdocs/SEAMS.md: verified dsh service seamsPLAN.md: interface mapping and rollout plan (sections AโD)TESTING.md: full automated/live test log and the manual Telegram acceptance checklistCHANGELOG.md: version history
Safety
- Only whitelisted chats are handled; an empty allowlist denies all inbound traffic
- Agent tools can only target chats in the allowed roster
- Callback payloads are percent-encoded and decode safely; tokens are single-use and bounded
- User/agent content is always HTML-escaped before wrapping; long HTML is split without breaking tags
- The bot token is read only from the documented chain (env โ
TELEGRAM_BOT_TOKEN_FILEโ gitignored.telegram-token); the plugin itself never writes it anywhere and never logs it; credential values never ride back - Permanent Telegram 4xx errors are never retried; a per-call timeout keeps a hung request from wedging the send chain
License
MIT