dispositif lets a Claude Code agent answer Telegram messages when no Claude session is open. It is for people who already run mcp-tg on a Telegram account set aside for their agent. It polls that account, passes through only the messages an allowlist admits, and answers each one with a single headless claude -p run. What the run is allowed to do depends on the rule that admitted the message.
It runs on macOS and Linux and needs an mcp-tg daemon serving MCP over streamable HTTP plus the claude CLI; the flags it passes exist in Claude Code 2.1.282. Telegram media (voice, photos, files) is not handled: such messages reach the run as a placeholder like [photo].
- Each poll cycle opens one short MCP session to mcp-tg:
tg_dialogs_list, thentg_messages_listfor every dialog that has unread messages, is named by a rule, or has not been seen before. The session is closed with an HTTPDELETEat the end of the cycle. - Each new message is checked against the rules in order. The first rule that matches decides the message's trust tier. A message that no rule matches is skipped and never shown to a model.
- An admitted message is marked read. It then gets a typing indicator, kept alive for the whole run, the last
historymessages of the chat as context, and oneclaude -p --output-format jsonrun in the tier's working directory, with the tier's flags. - The run's final text is posted as a reply to that message, in that chat. If the run fails or times out,
fallback_replyis posted instead. If the result is empty, nothing is posted.
Telegram delivers a forward with a comment, or an album, as several messages sent in the same second. Consecutive messages from one sender in one chat, each within batch_quiet_secs of the one before and with no one else's message between them, get one run that sees them all, forwarded ones marked, and one reply to the last. A batch waits until no further message can join it: batch_quiet_secs after its newest message, plus up to a second, since Telegram dates messages in whole seconds. The chat's read position stays before it meanwhile, so a restart neither loses nor repeats it, and the chat is polled even if it shows nothing unread. Each cycle reads a chat back to its read position, up to 300 messages; a gap beyond that is logged. A forward without a mention rides along with the comment that has one; a batch never mixes trust tiers.
A tier with dm_peer splits the answer. The run must end with a JSON object {"comment": "...", "dm": "..."}: the comment is posted as the reply, the note goes privately to dm_peer, and an empty string sends nothing. Nothing reaches the reply chat unless the run succeeded and its answer parsed; a failure or unparsed text goes to dm_peer alone, and so does a comment Telegram refused.
This is how the agent comments on a channel. A channel's posts reach its discussion group as messages sent by the channel, and a reply to one of them shows up under the post as a comment. A rule with sender = "channel" admits them; min_chars skips short ones. A long post that the client splits into several messages is answered once: the runner waits until the post is 35 seconds old, joins the consecutive parts sent within 30 seconds of each other, and replies to the last part.
When dispositif sees a chat for the first time, it skips messages dated before the process started and processes newer ones. So a restart with empty state does not answer old history, while a message that arrives in a new chat after start is still answered.
Claude sessions are kept per chat and tier (peer:trust). A new run resumes the previous session until that session is session_ttl_secs old.
- The allowlist runs before the model. Admission depends on the dialog peer, the sender id, and the trigger (any message, or a mention of the agent or a reply to it). A message no rule admits never starts a run. It can still reach a prompt as context: an admitted run is given the chat's last
historymessages, whoever wrote them, and the chat title. Lines from senders outside the allowlist are marked(outside allowlist)and the prompt tells the model to act only on the message it answers; that is an instruction to the model, not a boundary. So a run started in a group reads what every member wrote there, and the tier that handles it should be one you would give to the least trusted of them. Put a group's own rule, with a restricted tier, before anypeer = "*"rule that maps the owner to an unrestricted tier, as the example config does; otherwise the owner mentioning the agent in a shared group starts an unrestricted run over text others planted. - Tiers map trust to capabilities. A
restrictedtier runs with--restricted --strict-mcp-config --tools <tools>. That means no shell or other code-running tools, no MCP servers (so no Telegram access), file tools confined to the tier'scwd, user and project settings ignored, andbypassPermissionsrefused.checkaccepts only read-only tools in a restricted tier's comma-separatedtoolslist (Read,Grep,Glob,WebFetch,WebSearch) and rejectspermission_mode = "bypassPermissions"there. Anything else is refused because it runs code or writes files: a file written into a repository, such as a git hook, can run code later. An unrestricted tier runs with the full Claude Code configuration it finds throughclaude_envandcwd. - The runner picks the reply chat, not the model. The reply always goes to the chat the message came from, and a split tier's note to its one configured
dm_peer. A restricted run has no Telegram tools, so it cannot write anywhere else. - A rule checks the sender's kind as well as its id. A channel's id and a user's id share one number space, so a
sender = "user"rule never admits a channel, nor the reverse; anonymous group admins match no rule.
It does not protect against:
- A compromised allowlisted account. Whoever controls it gets that rule's tier.
- The prompt texts.
preamble, tierinstructionsandfallback_replycome from your config verbatim, and nothing checks what they say. - Anything an unrestricted tier is configured to do.
fullmeans your own Claude Code profile with its tools, MCP servers and permission mode. - Reading. A restricted tier can still read everything under its
cwdand fetch web pages if itstoolsallow it, and whatever it reads can end up in a reply.
From source (Rust toolchain required):
cargo install --git https://github.com/sanchpet/dispositifOnce a release is published, builds for macOS (aarch64-apple-darwin, x86_64-apple-darwin) are attached to GitHub releases as dispositif-<target>.tar.gz, which mise can install:
mise use -g github:sanchpet/dispositifdispositif check --config /path/to/config.toml # validate, print rules and tiers
dispositif watch --config /path/to/config.toml --once # print admitted messages as JSON lines, run nothing
dispositif run --config /path/to/config.toml # poll and answer, foreverThe config path can also come from DISPOSITIF_CONFIG.
check exits non-zero and lists every problem if the config cannot be parsed, a rule names a tier that does not exist, a peer or sender id is malformed, a restricted tier has no tools or a tool that is not read-only, an unrestricted tier sets tools, a permission_mode is unknown, or fallback_reply is empty. A tier cwd that does not exist on the machine is reported as a warning only.
watch prints one line per admitted message:
{"event":"message","trust":"full","rule":"owner-dm","peer":"1000002","chat":"Owner","id":42,"from":"Owner","fromId":1000002,"replyTo":null,"type":"text","text":"hello"}A batch prints as one line for its last message, with all its texts in text and a parts array of {"id", "type", "text", "forwardedFrom"}, oldest first.
When a poll fails, watch prints {"event":"error","error":"..."} once and prints {"event":"recovered"} once polling works again. With --once, a failed cycle exits with status 1.
run logs one timestamped line to stderr per event, finished run (with cost and turns), and failure.
State lives in $DISPOSITIF_STATE_DIR, or ~/.local/state/dispositif if that is unset. state.json holds the last processed message id and known agent message ids per chat. sessions.json holds the Claude session per chat and tier. Deleting both is safe, because old history is not replayed. watch and run share this state, so run only one of them against a given account.
examples/config.toml is a commented two-tier config with placeholder ids.
Top level:
| Key | Default | Meaning |
|---|---|---|
mcp_url |
http://127.0.0.1:8788 |
mcp-tg streamable HTTP endpoint; plain http:// only |
interval_secs |
10 |
pause between poll cycles |
batch_quiet_secs |
5 |
consecutive messages of one sender this close together are answered as one batch, once the sender has been quiet this long; 0 answers every message on its own |
agent_id |
required | the agent account's Telegram user id; its own messages never match |
agent_username |
required | without @; @<username> anywhere in a message (case-insensitive, not followed by a letter, digit or _) is a mention |
claude_bin |
claude |
path, or name looked up on PATH |
claude_env |
{} |
extra environment for runs; a leading ~ in values is expanded |
session_ttl_secs |
86400 |
how long a chat's Claude session is resumed |
run_timeout_secs |
900 |
a run is killed after this and answered with fallback_reply; a run's whole process group is killed when it ends either way |
history |
15 |
recent messages given to the run as context |
typing_interval_secs |
4 |
how often the typing indicator is re-sent while a run works; Telegram drops it after about five seconds |
preamble |
required | first part of every prompt |
fallback_reply |
required, non-empty | posted when a run fails |
[[rule]], tried in order, first match wins:
| Key | Meaning |
|---|---|
name |
unique label, shown in logs and events |
peer |
dialog id as mcp-tg prints it (1000002 user, -1000003 group, -100… supergroup or channel), or * for any chat |
from |
sender user ids (positive) this rule admits |
trigger |
any: every message. mention_or_reply: only a mention of the agent or a reply to one of its messages. In a direct message, mention_or_reply counts as satisfied. |
trust |
name of the [tier.<name>] that handles the message |
[tier.<name>]:
| Key | Meaning |
|---|---|
cwd |
working directory of the run; ~ expanded |
permission_mode |
passed as --permission-mode when set; one of acceptEdits, auto, bypassPermissions, default, dontAsk, manual, plan |
restricted |
true adds --restricted --strict-mcp-config --tools <tools> --allowedTools <tools>: --tools makes a tool available, --allowedTools grants it, and a headless run has no one to ask |
tools |
required when restricted, refused otherwise; comma-separated, from Read, Grep, Glob, WebFetch, WebSearch |
git_pull |
true runs git pull --ff-only in cwd before each run, so a tier without a shell reads current code; a failure is logged and the run goes on |
instructions |
added to the prompt after the preamble |
The prompt of each run is the preamble, then the tier instructions, then Chat: <title> (peer <peer>). Recent messages: followed by one [id] name (reply to N) (forwarded from X): text line per message, then Answer this message [id] from <name>: followed by the text; for a batch, Answer these N messages from <name> with one reply, followed by one [id] (forwarded from X): text line per message, and the recent messages end before the batch. Further lines of a message are indented by four spaces, and those of a forwarded message also start with > , so no text can pass for a message line. Blank lines separate the parts.
~/Library/LaunchAgents/com.example.dispositif.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.dispositif</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/dispositif</string>
<string>run</string>
<string>--config</string>
<string>/path/to/config.toml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardErrorPath</key>
<string>/path/to/logs/dispositif.log</string>
</dict>
</plist>launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.dispositif.plist
launchctl print gui/$(id -u)/com.example.dispositif | grep state # state = running
tail -f /path/to/logs/dispositif.log # "runner started"launchd starts jobs with a minimal PATH, so set claude_bin to an absolute path. Also give any tool the Claude profile starts (MCP servers, hooks) an absolute path, or add an EnvironmentVariables dictionary with PATH. If claude is not found, each run logs run failed peer=<peer> msg=<id>: starting claude: No such file or directory (os error 2) and the chat receives fallback_reply.
To stop: launchctl bootout gui/$(id -u)/com.example.dispositif.
mcp-tg is an MCP server for the Telegram client API (a user account, not a bot). dispositif is only a client of it. It uses tg_dialogs_list, tg_messages_list, tg_messages_get, tg_messages_mark_read, tg_typing_send and tg_messages_send, with MCP protocol version 2025-11-25, and accepts both plain JSON and SSE responses. Point mcp_url at the mcp-tg instance logged in as the agent's account, not your own: everything dispositif marks read or sends, it does as that account.
dispositif never keeps an MCP session open between cycles. mcp-tg sends keepalive pings on an SSE stream this client does not open, and closes sessions whose pings cannot be delivered.
MIT