Chat platforms — Telegram, Discord, Slack, your own app
How to put a real tool-using agent into a chat thread.
A Jazz agent in a chat window isn’t a chatbot with your logo on it. It’s the same agent that reads your filesystem, runs git, searches the web, and spawns sub-agents — reachable from your phone.
| Platform | Status | Where |
|---|---|---|
| Telegram | ✅ Deployable reference bridge | packages/telegram-bot/ |
| Discord | ✅ Deployable reference bridge | packages/discord-bot/ |
| Slack | 🔧 Bring your own bridge | pattern below |
| Google Chat | 🔧 Bring your own bridge | pattern below |
| Your own app | 🔧 Bring your own bridge | pattern below |
Be clear on what ships. Telegram and Discord are complete, production-deployed services with a Dockerfile, per-conversation model switching, reminders, and live progress. Slack and Google Chat do not ship an adapter. What they share is the contract — and the transport-specific part of a bridge is small enough that copying a shipped one and swapping the transport is the intended path, not a workaround.
The bridge pattern
Every chat bridge is the same three responsibilities. Only the middle one is platform-specific.
flowchart TB
subgraph platform["Platform-specific (~100 lines)"]
direction TB
IN["Receive a message<br/>webhook or long-poll"]
AUTH["Authorize the sender<br/>allowlist"]
FMT["Format the reply<br/>markdown → mrkdwn / HTML / embeds"]
OUT["Post the reply"]
end
subgraph jazz["Jazz (zero lines)"]
direction TB
RUN["<b>jazz run --json</b><br/>--conversation chat-id<br/>--approval-policy low-risk"]
MEM["History, tools, skills,<br/>model, cost accounting"]
end
IN --> AUTH
AUTH -->|allowed| RUN
AUTH -->|denied| DROP["Ignore"]
RUN --> MEM
MEM --> RUN
RUN --> FMT
FMT --> OUT
classDef mine fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef theirs fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class IN,AUTH,FMT,OUT,DROP mine
class RUN,MEM theirs
You write the orange boxes. You do not write session storage, context management, tool
dispatch, approval logic, or cost tracking — --conversation and --approval-policy
cover those. See Headless for the contract in full.
Telegram (shipped)
cd packages/telegram-bot/src
cp .env.example .env # set TELEGRAM_BOT_TOKEN + TELEGRAM_ALLOWED_CHAT_IDS + a model key
docker compose up -d --build
That’s a working agent in your DMs. For the account-creation steps (bot token, chat id),
see Creating a Telegram or Discord bot; for the full
configuration table and security notes, see
packages/telegram-bot/README.md.
What the Telegram bridge demonstrates — worth reading before you write your own:
| Feature | How it works |
|---|---|
| Per-user agents | Each chat gets tg_<chat_id>.json, cloned from a template on first contact. /model and /persona change only that user’s experience. |
| Per-user isolation | Each chat’s agent runs as its own Unix user, in its own Jazz home under /data/chats/tg_<chat_id>/. One allowlisted person’s agent cannot read another’s transcripts, memory, secrets or mail credentials — the kernel refuses, rather than a filename convention discouraging it. |
Any-provider /model | Bare /model lists the current provider’s models; /model provider/model (e.g. /model anthropic/claude-sonnet-5) switches to any provider Jazz supports — set that provider’s API key as an env var on the bot first (see .env.example). |
| Per-chat memory | --conversation <chat_id>. The bridge itself is stateless. |
| Live progress | --events NDJSON on stderr drives a status bubble that updates with thinking, tool calls, and sub-agents, then closes with a ✅ Done · 7 tools · 12k tokens · $0.03 summary. |
| Cancellation | A ⏹ button kills the child process mid-run. |
| Approvals | Each tool needing a human gets its own accept/reject message. A parallel batch of tool calls grows ⚡ Approve all N / 🚫 Reject all N so the whole batch clears in one tap, and /mode opts a conversation out of prompting altogether (yolo runs at high-risk). Both bridges do this. |
| Reminders | /remind 30m …, persisted to disk so they survive restarts and fire late if the bridge was down. |
| Spend cap | JAZZ_DAILY_COST_CAP_USD — known costUSD is accumulated per day; after an unpriced run, further requests pause until the next UTC day. |
| Local-only mode | Point JAZZ_TELEGRAM_PROVIDER=ollama at a local model: no keys, no cloud, no per-message cost. |
| Allowlist | Only TELEGRAM_ALLOWED_CHAT_IDS are answered; everyone else is silently ignored. |
The message flow
sequenceDiagram
autonumber
participant TG as Telegram
participant BR as bridge (Bun)
participant JZ as jazz run
participant LLM as Model + tools
TG->>BR: getUpdates long-poll → message
BR->>BR: chat id in allowlist?
BR->>TG: sendChatAction "typing…"
BR->>JZ: spawn: --json --conversation chat-id
JZ->>LLM: iterate: reason → call tools → observe
JZ--)BR: stderr NDJSON: tool_execution_start, subagent_start…
BR--)TG: edit status bubble (live)
LLM-->>JZ: final answer
JZ-->>BR: stdout: one JSON envelope
BR->>TG: sendMessage (markdown, new message so it notifies)
BR->>TG: edit bubble → "✅ Done · 7 tools · 12k tokens · $0.03"
Discord (shipped)
cd packages/discord-bot/src
cp .env.example .env # set DISCORD_BOT_TOKEN + an allowlist + a model key
docker compose up -d --build
DM the bot, or @mention it in an allowlisted channel. For the account-creation steps
(application, intents, invite URL), see
Creating a Telegram or Discord bot; for the full configuration
table and mention-gating details, see
packages/discord-bot/README.md.
Same jazz run contract as Telegram. What Discord adds on top:
| Feature | How it works |
|---|---|
| Mention-gating | In servers the bot ignores chatter unless mentioned, replied-to, or already in the thread. DMs always respond. |
| Thread binding | An @mention in a channel starts a thread; --conversation is the thread id so the rest of the room is not the chat. |
| 3-second ack | Slash commands and buttons are acknowledged immediately, then the agent run continues asynchronously. |
| Allowlists | Users, channels, and/or guilds. At least one is required. |
Any-provider /model | Bare /model shows a select menu of the current provider’s models; send /model provider/model (e.g. /model anthropic/claude-sonnet-5) as a normal message — not the slash-command menu, which can’t take a free-form value — to switch provider outright. Set that provider’s API key as an env var on the bot first (see .env.example). |
Slack, Google Chat
No adapter ships. Here’s what changes from the shipped bridges, and it really is just the edges:
| Concern | Telegram | Discord | Slack | Google Chat |
|---|---|---|---|---|
| Inbound | getUpdates long-poll or webhook | Gateway websocket | Events API webhook (or Socket Mode) | Chat app webhook |
| Conversation key | chat_id | DM channel id, or thread id | channel + thread_ts | space + thread name |
| Reply formatting | Markdown / HTML | Markdown (close to standard) | mrkdwn (*bold*, no # headings) | app card or plain text |
| Live progress | edit the status message | edit the status message | chat.update on a placeholder | update the card |
| Ack deadline | none | 3 s for interactions | 3 s — ack, then reply async | 30 s |
| Authorization | chat-id allowlist | user / channel / guild allowlist + mention-gating | verify signing secret, then allowlist | verify bearer token |
Two things to get right, both platform-side:
- Ack fast, answer later. Slack will retry a webhook you don’t ack within 3 seconds,
and an agent run takes longer than that. Ack immediately, run
jazz runin the background, and post the answer as a follow-up. Retries are also why you should de-duplicate on the platform’s event id — otherwise a slow run gets billed twice. - Translate the markdown.
jazz runwithout--jsongives you raw markdown precisely so you can convert it. Slack’smrkdwnin particular is not markdown.
Everything else — memory, tools, approvals, cost — you get from the flags.
Sending yourself a message
A bridge is a bot, and a bot can post without being asked. Once one is running you have a push channel to your own phone that anything on that machine can use — a script, a cron job, a long-running agent run, another session on another host, you at a shell. It does not have to be about the bridge, and it does not have to be about a deploy.
Each bridge ships a notify.sh next to it that takes one argument:
~/jazz/packages/telegram-bot/src/notify.sh "backup finished, 41 GB, no errors"
~/jazz/packages/telegram-bot/src/notify.sh "$(df -h / | tail -1)"
~/jazz/packages/telegram-bot/src/notify.sh "training run 7 done — val loss 0.312"
~/jazz/packages/discord-bot/src/notify.sh "nightly update rolled back, needs a look"
It reads the same .env the bridge runs on and posts to the first allowed chat
(TELEGRAM_ALLOWED_CHAT_IDS, or DISCORD_ALLOWED_CHANNEL_IDS).
What makes it worth reaching for over any other alerting: no run is started and no model is called. It is a single API call, so it costs nothing, needs no provider key, and works while the agent is busy, wedged, or not running at all — which is exactly when you most want to hear from the machine. It also means you can call it from inside something the agent is doing without recursing into a new run.
Chain it onto anything long:
./long-job.sh && notify.sh "long-job: done" || notify.sh "long-job: FAILED ($?)"
Or hand it to cron, where it replaces the usual habit of appending to a logfile
nobody opens. That habit has a real cost: a nightly updater on one box failed for
over two weeks before anyone noticed, because its only output went to
~/jazz-autoupdate.log. auto-update.sh now calls notify.sh instead.
It exits non-zero and explains itself if the credentials are missing, so a caller can note that without failing whatever it was doing:
notify.sh "..." || echo "(notify failed)"
Doing it without the script
Useful from a machine that has no checkout, or when the script itself is what is broken. The shape matters more than the URL:
ENV=~/jazz/packages/telegram-bot/src/.env
token=$(sed -n 's/^TELEGRAM_BOT_TOKEN=//p' "$ENV" | tail -1)
chat=$(sed -n 's/^TELEGRAM_ALLOWED_CHAT_IDS=//p' "$ENV" | tail -1 | cut -d, -f1)
curl -sS -o /dev/null -X POST \
"https://api.telegram.org/bot${token}/sendMessage" \
--data-urlencode "chat_id=${chat}" \
--data-urlencode "text=multi-line messages
work fine this way"
Three details that are easy to get wrong:
- Read the token, don’t print it. Assign it to a variable; never
catthe.envorechothe token. Anything that reaches a terminal reaches shell history, CI logs, and whatever is reading over your shoulder. - Use
--data-urlencode, not a JSON body. It handles newlines and any&,#or quote in the message without escaping, which matters when the text is command output or an error string you did not write. - Send no
parse_mode. Telegram rejects the whole request if the text does not parse as the markup you claimed, and piped-in output is exactly where an unbalanced*or_turns up. Plain text always sends.
Telegram caps a message at 4096 characters and rejects anything longer, so pipe
long output through tail -c 4000 rather than sending it whole.
Discord’s equivalent needs a bot token in an Authorization: Bot … header and a
JSON body, so escaping is on you — which is the main reason to prefer notify.sh.
Security for chat surfaces
A chat surface accepts input from other people. That changes the threat model in a way worth being blunt about.
flowchart LR
STRANGER["Message from<br/>a person"] --> AGENT["Agent<br/>(full toolset)"]
AGENT --> POLICY{"--approval-policy"}
POLICY -->|read-only| SAFE["Reads and searches only"]
POLICY -->|low-risk| MILD["+ todos, sub-agents"]
POLICY -->|high-risk| DANGER["+ shell, git push,<br/>file deletion<br/><b>on the host</b>"]
classDef ok fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef warn fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef bad fill:#c1443c,stroke:#7d2b26,color:#ffffff
class SAFE ok
class MILD warn
class DANGER bad
- Always use an allowlist. Both bridges and Jazz have one; use both.
- Default to
low-risk. Athigh-risk, a message — or a prompt injection inside a web page the agent fetched — can run arbitrary commands on the host. That is the documented behavior of that tier, not a bug. - Know what “yolo” costs. Both bridges’
/mode yoloishigh-riskfor that conversation, and it is sticky — it survives/newand bridge restarts until someone sets it back to safe. Anyone on the allowlist can set it for their own conversation. - Trim the toolset. An agent config that doesn’t include
execute_commandcannot run shell commands regardless of policy. This is the strongest control available. - Treat the history volume as sensitive. Transcripts are plaintext JSON under
~/.jazz/history/. - Allowlisting is not isolation. Two people on the same allowlist share a host, and a Jazz agent has
read_fileandexecute_command— so without an OS boundary either one’s agent can read the other’s transcripts, memory and stored credentials. Both bridges give each conversation its own uid and Jazz home for exactly this: Telegram, Discord. It matters most where the allowlist is a guild, since that admits everyone in it. Anything you build yourself needs the same, or a one-person allowlist. - A container is not a boundary against the host. Root,
sudo, and thedockergroup all read a bridge’s volume whatever its uids and file modes say — the daemon runs as root, and the docker group is root-equivalent. On a machine other people administer, treat everything the bot has stored as readable by every admin on it. - Cap spend. Use
costKnownas well ascostUSD. The bridges pause subsequent requests after an unpriced run; no dollar cap can guarantee the cost of that first unpriced request.
Full model: Security.
Related
- Headless — the contract every bridge uses
packages/telegram-bot/— Telegram reference implementationpackages/discord-bot/— Discord reference implementation- Airgapped & self-hosted — running a bridge with no cloud provider