▞ jazzdocsblogpersonas
github

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.

PlatformStatusWhere
Telegram✅ Deployable reference bridgepackages/telegram-bot/
Discord✅ Deployable reference bridgepackages/discord-bot/
Slack🔧 Bring your own bridgepattern below
Google Chat🔧 Bring your own bridgepattern below
Your own app🔧 Bring your own bridgepattern 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:

FeatureHow it works
Per-user agentsEach chat gets tg_<chat_id>.json, cloned from a template on first contact. /model and /persona change only that user’s experience.
Per-user isolationEach 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 /modelBare /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.
CancellationA ⏹ button kills the child process mid-run.
ApprovalsEach 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 capJAZZ_DAILY_COST_CAP_USD — known costUSD is accumulated per day; after an unpriced run, further requests pause until the next UTC day.
Local-only modePoint JAZZ_TELEGRAM_PROVIDER=ollama at a local model: no keys, no cloud, no per-message cost.
AllowlistOnly 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:

FeatureHow it works
Mention-gatingIn servers the bot ignores chatter unless mentioned, replied-to, or already in the thread. DMs always respond.
Thread bindingAn @mention in a channel starts a thread; --conversation is the thread id so the rest of the room is not the chat.
3-second ackSlash commands and buttons are acknowledged immediately, then the agent run continues asynchronously.
AllowlistsUsers, channels, and/or guilds. At least one is required.
Any-provider /modelBare /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:

ConcernTelegramDiscordSlackGoogle Chat
InboundgetUpdates long-poll or webhookGateway websocketEvents API webhook (or Socket Mode)Chat app webhook
Conversation keychat_idDM channel id, or thread idchannel + thread_tsspace + thread name
Reply formattingMarkdown / HTMLMarkdown (close to standard)mrkdwn (*bold*, no # headings)app card or plain text
Live progressedit the status messageedit the status messagechat.update on a placeholderupdate the card
Ack deadlinenone3 s for interactions3 s — ack, then reply async30 s
Authorizationchat-id allowlistuser / channel / guild allowlist + mention-gatingverify signing secret, then allowlistverify bearer token

Two things to get right, both platform-side:

  1. 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 run in 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.
  2. Translate the markdown. jazz run without --json gives you raw markdown precisely so you can convert it. Slack’s mrkdwn in 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 cat the .env or echo the 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. At high-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 yolo is high-risk for that conversation, and it is sticky — it survives /new and 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_command cannot 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_file and execute_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 the docker group 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 costKnown as well as costUSD. The bridges pause subsequent requests after an unpriced run; no dollar cap can guarantee the cost of that first unpriced request.

Full model: Security.


machine-readable: /docs/use-cases/chat-platforms.md · /llms.txt · /llms-full.txt