Passer au contenu principal
A

dsh-telegram

ashafizullah/dsh-telegram

Telegram channel for DeepSeek Harness — real Telegram markdown, and answer the agent's questions and approvals straight from chat

Installer

dsh plugin --profile web add github:ashafizullah/dsh-telegram

README

dsh-telegram

Talk to your agent from Telegram — and actually answer it when it asks something.

CI npm license node Bot API DeepSeek Harness

English · Bahasa Indonesia · 中文

A Telegram front end for DeepSeek Harness.

Talk to your agent from your phone — and actually answer it when it asks something.

Why this exists

Running an agent from a chat app breaks down at two specific points, and this plugin is built around fixing them.

The agent writes markdown; Telegram was getting it raw. Models answer with **bold**, headings, tables, task lists and fenced code. Sent as plain text, all of that arrives as literal asterisks and pipes.

Since Bot API 10.1 Telegram parses markdown itself, so this plugin forwards the agent's reply almost verbatim through sendRichMessage — tables render as tables, checklists as checklists — and the message cap rises from 4096 to 32768 characters with it.

The agent asks questions; there was nowhere to answer them. When the agent calls ask_user_question, or a tool needs your permission, the harness blocks and waits for a UI to answer. Only the browser could. A conversation held entirely in Telegram would stall on the first question with no way to clear it. This plugin registers itself as that UI, so questions and approvals arrive as buttons in the chat.

Requirements

  • DeepSeek Harness with a profile you can add plugins to
  • Bot API 10.1 or later, for sendRichMessage and sendRichMessageDraft
  • Node 22 or later

There is no HTML fallback. Telegram's rich markdown parser is forgiving — an unterminated code fence or a line of stray markers is accepted rather than rejected — so a mid-stream frame does not need one.

Install

npx @deepseek-ai/dsh plugin --profile web add -w @ashafizullah/dsh-telegram

Or from a checkout, to develop against it:

git clone https://github.com/ashafizullah/dsh-telegram.git
cd dsh-telegram
pnpm install && pnpm build

npx @deepseek-ai/dsh plugin --profile web add -w "$(pwd)"

Then give it a bot token. Create a bot with @BotFather and store the token under the credential reference — never in a config file:

npx @deepseek-ai/dsh credentials set TELEGRAM_BOT_TOKEN

Start the profile. The console prints a claim code:

[dsh-telegram] this bot has no owner yet. Message @your_bot with:

    /claim 3f9a2b1c

Send that to your bot and it is yours. Until then it answers nobody.

The code is also written to $DSH_HOME/dsh-telegram/claim-code.txt, owner-only, because several profiles compose no console sink at all and a code nobody can read makes the bot permanently unusable.

Access

A Telegram bot is reachable by anyone who knows its handle, and the agent behind it can run shell commands on your machine. So the default is closed.

  • Claim flow (default): the first person to send the console-printed code becomes the owner. Ownership is durable and single-shot — a later claim is refused even with the right code, so a leaked code grants nothing.
  • Allowlist: set allowFrom to a list of Telegram user ids to skip claiming entirely. Use /whoami to find your id.

The claim code changes on every restart and is never sent over Telegram.

Commands

CommandWhat it does
/startWhat this bot is, and whether you may use it
/helpList the commands
/claim <code>Take ownership of an unclaimed bot
/newStart a fresh conversation, forgetting the current one
/cd [path]Show or change the working directory
/model [what]Show the model, /model list, or switch to one
/effort [level]Show or change how hard the model thinks
/vision [what]Show, change, or turn off the model that reads images
/permission [name]Show or change what the agent may do here
/diagWhat the plugin can see about itself, and recent failures
/screenshotSend a picture of the harness machine's screen
/sessionsPick up an earlier conversation from this chat
/statusSession id, working directory, and whether it is loaded
/stopCancel whatever the agent is doing right now
/whoamiYour Telegram user id

In a group

A bot that answers every line is one nobody keeps in the room, so in a group it answers only when @mentioned or replied to — the convention people already use. Its own mention is stripped before the prompt, because that is addressing rather than content, and replying to something it said continues the exchange without an @mention on every line. Private chats are untouched. Set requireMentionInGroups to false for the older behaviour.

The mention is compared against Telegram's own parsed span rather than searched for in the text: @mybot_staging contains @mybot, and a substring match would hand another bot's mentions to this one.

What the agent is allowed to do

A deployment picks one permission default for everything it runs, usually with the web UI in mind: loopback-only, with a person watching. A Telegram bot is reachable from anywhere and gated by a list of user ids, so the same danger-full-access reads differently there. permissionPreset names one of the deployment's own presets for Telegram conversations alone.

It also decides whether the approval buttons work: under a preset whose approval policy is never nothing ever asks, so they can never appear. Choosing one that asks is what turns them on.

A picture of the screen

/screenshot sends what the harness machine is showing. It is the reason the bot exists, applied to the screen itself: the machine is at a desk and you are not, so checking what a long build is showing needs a trip back to the keyboard otherwise.

It is off by default, and the switch is a deployment setting rather than a chat command on purpose. A screen holds whatever happens to be on it — an open password manager, someone else's messages, an unrelated customer's data — and this is the one thing here that sends the machine's own contents outward without the agent being involved. Turning it on should take the same access as configuring the bot.

macOS also needs Screen Recording permission for the process running the harness. Without it screencapture still succeeds and returns the desktop picture with no windows, which looks like a broken feature rather than a missing permission, so that case is named rather than shrugged at. Grant it in System Settings → Privacy & Security → Screen Recording and restart the harness.

A capture over Telegram's 10 MB photo limit is sent as a document instead, which takes 50 MB — a large display's PNG routinely needs it.

Effort, and what the agent may do

/effort shows how hard the model thinks and lists what that model offers — read from the model itself, because low/medium/high is one provider's vocabulary rather than everyone's, and offering an effort a model does not have would fail the turn instead of the command. /effort default gives it back.

/permission shows what the agent may do here and switches it: read-only, workspace-write, danger-full-access, or whatever else the deployment defines — the names are read from its own table, not fixed here. Spelling is forgiving, so full access, full-access and readonly all land, and a shorthand matching two presets is refused rather than guessed at. The change applies to the conversation in flight as well as the next one, because the reason to tighten it is usually the turn about to run.

Each of these is per conversation and sits on top of what the settings page configures. That is two surfaces showing related state, so the commands say which layer answered: once a conversation has chosen for itself, its reply names the deployment default underneath. Without that the page reads as though it were lying — it shows one thing while the chat does another, and nothing connects them. /… default gives a conversation back to the deployment.

/status answers all of it in one message — session, directory, model, effort, permission — since having to run four commands to learn what you are talking to is four commands too many.

Which model, and which conversation

/model says which model the conversation is on, /model list shows what is configured, and /model provider/model switches. A bare model id works when only one provider offers it; when several do, it asks which. Unlike /cd this does not restart anything — the harness reads a mutable selection while assembling each step, so the change lands on the next message with the history intact. /model default gives the conversation back to the deployment.

/sessions offers this chat's earlier conversations as buttons. /new is otherwise a one-way door: the harness keeps every log, but the binding naming the current one is replaced, and from a phone there is no other way back. The list is this plugin's own, so it holds conversations from this chat rather than every session the web UI ever opened.

Which tools the agent has

A preset supplies them. The registries are host-plane, but almost every model-facing row — bash, the editor, grep, skills, subagents, todo, plan mode — is registered into a preset's scope layer, so an agent that joins no preset reaches the model with only whatever the host composition registered globally. Telegram sessions are composed from the deployment's default preset, or from agentPreset when one is named, and the choice is recorded in the session header so a later reader resolves the same composition.

Where the agent works

/cd on its own says where the conversation is; /cd ~/projects/app moves it. Absolute paths, ~, and paths relative to where the conversation already is all work, and a pasted path keeps its quotes off.

Moving starts a fresh conversation, and the bot says so. That is not a shortcut: the sandbox derives its writable root from the session's working directory, and that root is fixed when the session opens — so a directory change is a new session by construction. The choice is remembered per chat and survives both /new and a restart, which is why it is kept apart from the session binding that /new discards.

A directory that does not exist, one that turns out to be a file, and one that cannot be read are three different mistakes and get three different sentences. Each leaves the conversation exactly where it was.

They are published to Telegram on every connection, so typing / in the chat offers the list with descriptions. /claim drops off it once the bot has an owner — it is the one command that stops working the moment it succeeds.

Anything else you type is a prompt for the agent.

What you can send

You sendWhat the agent gets
TextThe prompt
A photo, or an image sent as a fileWhat the vision model reads in it, and your caption
Several photos at onceAll of them in one message, under your caption
A text file — a log, a stack trace, sourceIts contents in the prompt, truncated if very long
A voice note, audio, or videoA note saying it could not be read

Images go through the harness attachment seam, which accepts PNG, JPEG, WebP and GIF. Everything else it explicitly defers, so this plugin says so rather than accepting the message and quietly dropping what it carried.

The seam also refuses an image whose longest side is over maxImageDimension, 2000 pixels by default — and every full-height phone screenshot is over it: 1179×2556 on an iPhone, 1080×2400 on most Android. Telegram sends a photo at several rendered sizes, so the largest one that fits is chosen rather than the largest one there is, and the seam's published limits are read from the store itself so there is no second copy of the number to drift. If the seam refuses a size anyway, the next smaller one is tried; when a photo sent uncompressed leaves nothing to step down to, the refusal names the limit and says that sending it as a photo would let Telegram offer a smaller copy.

A file that is too large, or that fails to download, becomes a note in the prompt explaining why — your caption still reaches the agent either way.

Sending several at once

Telegram has no "several photos in one message". An album arrives as N separate updates tied together only by a shared id, with the caption on exactly one of them — so three screenshots used to become three turns, two of them bare images the agent had no question for.

A message belonging to an album is now held rather than answered, and the group goes through as one prompt once it stops growing: your caption, then every image. The wait is paid only by albums, and only once each, which beats answering the same question three times.

A model has to be able to look

A model that declares no image input rejects the whole request, so an image is checked against inputModalities before it is sent. No DeepSeek model accepts imagesdeepseek-v4-flash and deepseek-v4-pro are both text-only — so out of the box a screenshot is declined with a sentence naming what would work, and your caption still reaches the agent.

Settings → Telegram → Attachments offers a dropdown of the models already configured in Settings → Models. Pick one and images become readable.

/vision chooses which model reads images here, or turns the reading off entirely with /vision off. Off is a real answer rather than the absence of one: a conversation whose own model can see wants no reader at all, and saying so outranks whatever the deployment configured. Like the rest, it is per conversation and survives /new.

If the conversation's own model reads images, none of this happens. The picture goes straight through, and the model looks at it. Since DeepSeek shipped deepseek-v4-flash-vision-exp that is a real choice rather than a hypothetical, and it is the better one when the screenshot is not just text: a transcription loses the diagram, the chart, the misaligned layout — everything you were actually asking about.

The indirection below exists because a provider inspects the whole request history, so an image binds a conversation to a model that can see. When that model IS the one you chose, there is nothing to be stuck on and nothing to work around — so the reading, the refusal, and the sticky routing all stand down together.

The picture never enters your conversation. It goes to a throwaway session on that model, which is asked to transcribe every piece of text in it and describe what it is; the reply comes back as ordinary text and that is what your conversation receives, under your own caption. The session is disposed either way — it exists for one turn.

The indirection is the point. A provider checks the entire request history for images, so an image left in a conversation binds it to a model that can see for as long as it lives: one screenshot and every later turn — however plain its own text — has to run there too, away from the model you chose and the tools configured around it. Reading it elsewhere keeps the history free of images, so the conversation stays where it was, keeps its tools, and never gets stuck.

With no vision model configured nothing reaches this path at all: the image is declined before it is even downloaded, with a sentence naming the models that would have worked, and your caption still reaches the agent.

If a reading is attempted and fails — the model unreachable, the turn timing out after two minutes — the picture goes through as it is and the conversation moves onto the vision model instead, durably, until /new. That is the fallback rather than the design, and the prompt says which happened.

The catalog the browser can read carries no modality information, so the dropdown cannot mark which models accept images. The host checks that when an image is actually sent, which is the one place the answer is certain. Vision models reach the harness through a provider that carries them, such as an OpenAI-compatible route added in Settings → Models, whose model entry declares input: [text, image].

When no model can look

With no vision model configured the image used to be refused outright, and the answer was a sentence about model configuration rather than anything about the picture. If tesseract is installed, its text is read instead.

It is a fallback and says so. OCR reads text; it does not see. A screenshot of an error, a log or a receipt comes back cleanly — crisp text, high contrast, no perspective is exactly its best case — while a whiteboard, an architecture diagram or a chart comes back as scattered words with nothing to say what the picture was. The reading is therefore labelled as OCR wherever it goes: an agent handed unlabelled OCR treats a misread digit as a fact, and a receipt's amount is precisely what it gets wrong.

Tesseract is never assumed. No operating system this runs on ships it, so its absence is the ordinary case: it is probed once, and where it is missing the old refusal stands — now naming both ways forward. /diag says which of the two this machine has.

The same fallback covers a vision model that was configured but could not be reached, for the same reason: reading the text beats returning nothing.

Latin script reads well with eng alone — Indonesian, numbers, dates and amounts all come through — so media.ocr.languages only needs changing for a different script. tesseract --list-langs says what is installed.

When a conversation gets stuck

A turn can fail in a way no retry clears — most often that one: an earlier message carries content the current model will not take, and nothing typed next will change it. The bot recognises those, says what failed, and offers a button that starts a fresh conversation. Asking the user to remember /new would be asking them to diagnose the plugin.

Failures that may pass on their own are reported without a button, because retrying really is the right thing to do with them.

Configuring it

Open Settings → Telegram in the harness web UI. The page writes straight to the settings document — there is no Save button, because the host applies a committed change by reconnecting, and a staged form would let the page and the running bot disagree about what is configured.

The bot token is the exception. It is a secret, so it never rides the settings wire in either direction: the page learns only whether one is stored, writes it through the credentials domain, and refuses to offer an edit for a reference the environment already supplies (a write there would look like it worked while resolution kept returning the shadowing value).

Everything on the page is equally settable in a profile patch, for a deployment that configures by file:

Configuration

Every field has a working default; an empty config runs.

KeyDefaultMeaning
enabledtrueWhether the connection starts with the harness
tokenRefTELEGRAM_BOT_TOKENCredential reference holding the token
baseUrlhttps://api.telegram.orgBot API origin; change only for a proxy
allowFrom[]User ids allowed in; empty enables the claim flow
cwdharness cwdDirectory a conversation starts in until /cd moves it
agentPreset""Preset Telegram conversations are composed from; empty takes the deployment default. The preset supplies the tools
permissionPreset""Permission preset Telegram runs under, from the deployment's own table; empty follows the deployment default
requireMentionInGroupstrueIn a group, answer only when @mentioned or replied to
screenshot.enabledfalseAllow /screenshot. Off by default; macOS also needs Screen Recording permission
streaming.enabledtrueShow the answer as it is written
streaming.throttleMs1200Minimum gap between streamed frames
timeoutMs30000Per-request Bot API timeout
longPollSeconds25How long Telegram holds an empty poll open
media.enabledtrueRead images and text files the user sends
media.maxBytes20 MBRefuse anything larger; Telegram caps bot downloads there
media.maxTextChars60000Truncate an inlined text file to this many characters
media.ocr.enabledtrueRead an image's text with tesseract when no vision model can. Does nothing unless tesseract is installed
media.ocr.languagesengLanguages tesseract reads; join several with +. Only installed ones work
media.visionModel""provider/model that reads images in a session of its own; empty sends the image to the conversation itself. Picked from a dropdown on the settings page
reconnect.baseDelayMs1000Delay before the first reconnect attempt
reconnect.maxDelayMs30000Longest delay between reconnect attempts

Diagnostics

/diag reports what the plugin can see about itself: the connection, which harness seams this deployment actually composed, and the last twenty things that went wrong.

It also says which versions are running and whether anything newer is published — read-only, and cached for an hour so asking twice costs nothing. There is deliberately no /update to go with it: updating the harness takes effect only after a restart, and restarting it from inside a plugin running in it kills the process answering you, with nothing to bring it back on a machine with no supervisor. Knowing you are behind is the useful half; acting on it belongs where you can watch it.

The seam list is the useful part. An absent seam explains a whole class of "why does it not do that" without anyone having to guess — a missing agentPresets is why Telegram agents once reached the model with almost no tools, and nothing anywhere said so.

ctx.logger reaches whatever sink the deployment composed, and several profiles compose none — so a plugin that only logs its failures is silent about them. This one also writes its state to $DSH_HOME/dsh-telegram/status.json on every transition:

{ "state": "connected", "bot": "your_bot", "updatedAt": "..." }

connecting, connected, idle with a reason, failed with a reason. The bot token never appears in it.

Living alongside the web UI

The harness allows exactly one user-questions provider, and in a profile that also runs the web app the browser has already claimed it. This plugin takes the slot over and keeps the browser's provider as a fallback: a question belonging to a browser session is forwarded straight back to it, and one belonging to a Telegram conversation becomes buttons in the chat. Unloading the plugin puts the previous arrangement back exactly.

Approvals compose natively — the harness runs them as a waterfall — so this plugin answers for its own sessions and passes every other one along.

How it fits together

Telegram Bot API
      │  long poll: message + callback_query
      ▼
UpdatePoller ──► UpdateRouter ──┬──► SessionRunner ──► ctx.agents
                                │           │
                                │           └──► VisionExtractor ──► a throwaway
                                │                                    session
                                ├──► MediaCollector ──► ctx.attachments
                                ├──► TelegramQuestionProvider ──► ctx.userQuestions
                                └──► TelegramApprovalAnswerer ──► approval/request

ctx.on('session/event') ──┬──► VisionExtractor   (its own reading sessions)
                          └──► TurnBridge ──► RichReplyStream ──► sendRichMessage

TypingIndicator          (held by the router and the bridge until a reply shows)

How a reply is streamed

Telegram offers two mechanisms, and they are not interchangeable:

  • Private chats use sendRichMessageDraft — an ephemeral preview that animates between frames sharing a draft id. It expires 30 seconds after its last frame, so a heartbeat re-sends the current text during a long tool call; otherwise the preview would vanish and the bot would look dead. A draft is never persisted, so the turn ends with a real sendRichMessage.
  • Groups have no draft API. There the finished reply is simply sent when it is ready.

Both end with one permanent rich message.

Nothing is posted until there is something to say

Telegram's own typing indicator carries the wait, and the reply appears only once it has content — the first words, or the name of a tool the agent reached for. An ellipsis posted the moment a turn opens tells the user what they already know, and in a group it is a permanent message telling them.

The indicator is held rather than sent. sendChatAction lapses after five seconds, which is shorter than almost everything worth waiting for here — downloading a file, reading an image on a vision model, a turn queued behind the last one, a minute inside a tool call — so one call reads as a bot that started and died. Holds are counted per conversation and re-sent inside their own expiry, so the router's hold while it reads an attachment and the bridge's hold over the turn that follows overlap cleanly, and typing stops when the last of them lets go. A ten-minute backstop covers a release that never arrives.

Nothing is ever redrawn with less than it showed before. When a tool finishes and there is no text yet, the line naming it stays until real text replaces it — Telegram refuses an empty draft, so the alternative was trading the last thing that happened for a frame that said nothing.

While the agent works, the running tool is shown above the reply in a <tg-thinking> block:

▸ bash: npm test

Here is what I found so far…

Telegram accepts that block in a draft and nowhere else, which matches its lifetime exactly — it disappears when the turn is persisted, so the finished reply carries the answer rather than the scaffolding that produced it. It is a single clipped line: a tool call's arguments can be an entire file, and the point is knowing the agent is alive, not reading a transcript.

Access is checked before anything else, so no unauthorised text reaches the agent — not even a command.

Development

pnpm install
pnpm test          # 598 tests
pnpm test -- --coverage
pnpm typecheck     # host and browser halves
pnpm build         # tsc for the host, esbuild for the browser bundle

Releases go out from .github/workflows/release.yml on a v* tag, over npm's trusted publishing: GitHub proves the workflow's identity over OIDC and npm exchanges that for a credential lasting one publish. There is no token stored anywhere and no one-time password to race — which matters when the account's second factor is a passkey rather than a code. The workflow refuses a tag that disagrees with package.json, since that is the one mistake it could otherwise publish silently.

Every module runs without a harness, which is what keeps the suite fast: the plugin entry is exercised against a real HTTP stub of the Bot API, and the browser bundle is materialized exactly as the shell materializes it.

The browser half

build.client.mjs wraps an esbuild CJS bundle in the shell's lazy-CJS factory envelope (window.__ModuleLoader__.load({ id, factory })). That envelope is reproduced rather than imported: the harness's clientBundle preset is not published, which its own documentation lists as a known limitation for plugins shipped outside its repository. It is therefore the single place this plugin is coupled to an internal format, and test/client-bundle.test.ts pins it — the test runs the build, materializes the factory with a stub require, and checks that apply claims its settings seat. A harness release that changes the format fails there by name instead of showing up as a blank Settings page.

React and the shell's own packages are marked external; bundling a second React would break every hook the moment the page mounted.

Known limitations

  • One directory per conversation. /cd moves a conversation, but a session cannot be moved: the change starts a fresh one.
  • No voice, audio or video. The harness attachment seam takes images only.

License

MIT

Plugins associés