docs blog library
github

Reaching your agent from a chat app

A hands-on walkthrough for going from nothing to a working Jazz agent in your Telegram DMs, a Discord server, Messages on a Mac, or WhatsApp. See Chat platforms to compare their capabilities, and each bridge’s own README for the full command/environment-variable reference: Telegram, Discord, iMessage, WhatsApp.

All four platforms give you the same thing: per-chat memory, per-chat /model and /persona, reminders, and attachments. They differ in where they can run and what the app can show. Telegram and Discord are Docker services. iMessage can use a hosted Photon line or your own Apple account on a Mac. WhatsApp links to your account the way WhatsApp Web does.

You need a model backend for any of them: an API key for a cloud provider (OpenAI by default), or a local Ollama with a tool-capable model pulled. Telegram and Discord additionally need Git, Docker, and Docker Compose. Clone the repository before their setup steps:

git clone https://github.com/lvndry/jazz.git ~/jazz

Their cd commands below use this checkout. iMessage and WhatsApp use the installed Jazz CLI and do not need a clone.


Telegram

1. Create the bot

Message @BotFather on Telegram:

  1. Send /newbot.
  2. Pick a display name, then a username ending in bot (e.g. my_jazz_bot).
  3. BotFather replies with a token that looks like 123456:ABC-DEF.... That’s TELEGRAM_BOT_TOKEN: treat it like a password.

2. Get your chat id

Message @userinfobot: it replies with your numeric id. That’s what goes on the allowlist so the bot only answers you (and anyone else you add).

3. Configure

cd ~/jazz/packages/telegram-bot/src
cp .env.example .env

Edit .env and set at least:

  • TELEGRAM_BOT_TOKEN: from step 1.
  • TELEGRAM_ALLOWED_CHAT_IDS: your id from step 2 (comma-separated if more than one).
  • TELEGRAM_OPERATOR_IDS: your id again, if you want to be able to turn approvals off with /mode yolo or use “Always allow”. See Operators.
  • A model backend. OPENAI_API_KEY is set by default (JAZZ_TELEGRAM_PROVIDER=openai, JAZZ_TELEGRAM_MODEL=gpt-5.4). To run fully local instead, set JAZZ_TELEGRAM_PROVIDER=ollama and JAZZ_TELEGRAM_MODEL=<a model you've pulled>.

4. Run it

Start the bridge from the directory containing your .env:

docker compose up -d --build
docker compose logs -f          # expect "Polling Telegram for updates…"

5. Talk to it

Message your bot on Telegram. It shows a “typing…” indicator, then the agent’s reply.

If nothing happens: check your chat id is actually on TELEGRAM_ALLOWED_CHAT_IDS, and that docker compose logs doesn’t show an auth error from Telegram (a copy-pasted token with a trailing space is the usual culprit).


Discord

1. Create the application and bot

  1. Open the Discord developer portal and sign in.
  2. New Application → name it (e.g. Jazz) → Create.
  3. Left sidebar → Bot → Reset Token → copy it. That’s DISCORD_BOT_TOKEN, treat it like a password.
  4. Still on the Bot page, under Privileged Gateway Intents, turn on Message Content Intent and Save. Without this the bot cannot read what people type in a server.
  5. Left sidebar → OAuth2 → copy the Client ID (also called Application ID): you’ll need it for the invite URL in the next step.

2. Invite it to your server

You need permission to add bots on that server (owner, or Manage Server).

Open this URL, replacing YOUR_APP_ID with the Client ID from step 1:

https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot%20applications.commands&permissions=311385246720

Pick your server → Authorize. The bot appears in the member list, offline until the bridge is running. Those permissions are: View Channel, Send Messages, Send Messages in Threads, Create Public Threads, Embed Links, Attach Files, Read Message History, Use Application Commands.

To restrict it to one channel: after inviting, edit that channel’s permissions to allow the bot role there, and deny (or don’t grant) View Channel everywhere else. Then put that channel’s id on the allowlist below instead of a server id.

3. Copy ids for the allowlist

In Discord: User Settings → Advanced → Developer Mode (on). Then right-click and Copy … ID:

You want…Right-click
Yourself (DMs + your @mentions)your avatar / username → Copy User ID
One channel onlythe channel → Copy Channel ID
The whole serverthe server name → Copy Server ID

For a private server, the usual choice is DISCORD_ALLOWED_GUILD_IDS=<server id> (anyone in the server can @mention the bot), or DISCORD_ALLOWED_USER_IDS=<your id> (only you, in DMs and in any server the bot is in).

4. Configure

cd ~/jazz/packages/discord-bot/src
cp .env.example .env

Edit .env and set at least:

  • DISCORD_BOT_TOKEN: from step 1.
  • One allowlist: DISCORD_ALLOWED_USER_IDS, DISCORD_ALLOWED_CHANNEL_IDS, and/or DISCORD_ALLOWED_GUILD_IDS.
  • DISCORD_OPERATOR_IDS: your user id, if you want to be able to turn approvals off with /mode yolo. With a guild allowlist everyone in the server can talk to the bot, so this is what keeps them from switching it to yolo. See Operators.
  • A model backend. OPENAI_API_KEY is set by default (JAZZ_DISCORD_PROVIDER=openai, JAZZ_DISCORD_MODEL=gpt-5.4). To run fully local instead, set JAZZ_DISCORD_PROVIDER=ollama and JAZZ_DISCORD_MODEL=<a model you've pulled>.

5. Run it

docker compose up -d --build
docker compose logs -f          # expect "Discord → Jazz bridge ready as @…"

6. Talk to it

  • In the server: @Jazz what's the weather in Lyon: it starts a thread and replies there. Follow-ups in that thread don’t need another mention.
  • DMs: only if your user id is on DISCORD_ALLOWED_USER_IDS.
  • Slash commands (/help, /status, /tz, …) show up in the server a few seconds after the bridge logs “ready”.

If it stays silent in the server: Message Content Intent is off, the channel isn’t allowlisted, or you didn’t @mention it (DISCORD_REQUIRE_MENTION=1 by default).


iMessage with a hosted line

The default gives the agent a line through Photon, so it can run on Linux or macOS and does not answer as your personal Apple account.

jazz imessage

Create a Photon project, then enter its project id and secret when Jazz asks. The first run also asks which handles may contact the agent. For a noninteractive server, provide PHOTON_PROJECT_ID, PHOTON_PROJECT_SECRET, and PHOTON_ALLOWED_HANDLES in the process environment. Messages pass through Photon; use the local mode below when that is not acceptable.

Full setup and limitations: packages/photon-bot/README.md.

iMessage through your own Mac

Local mode runs on a Mac running macOS 14 or newer, signed into iMessage, awake and logged in. Unlike Photon, it uses your Apple account: the same line your friends already text.

1. Start it

jazz imessage --local

To answer as an agent you already have rather than a fresh assistant, name it. It is copied into the bridge’s own home, so the original keeps its name and stays yours:

jazz imessage --local --agent nostra

2. Say yes twice

It walks through the two things it needs:

  • imsg, the CLI it reads and sends Messages through. It offers to install it.
  • Full Disk Access, so it can read your Messages. macOS keeps them in a protected database and this is the only permission that opens it. Jazz opens the right settings page and copies the path you need to add: click +, press Cmd-Shift-G, paste, then run it again.

The first message it sends also raises a one-time Automation → Messages prompt.

3. Talk to it

With nothing configured, a first run answers only you: text yourself jazz <question> from any of your devices. That is IMESSAGE_SELF_TRIGGER falling back to jazz precisely because no allow-list is set: it opens the bridge to one person, whoever is already signed in on this Mac, and to nobody else.

Two consequences of borrowing your own account, both visible in the chat:

  • A trigger word is needed for messages to yourself. iMessage marks everything you send as yours, the bridge’s own replies included, so in a chat with yourself it has to be told which lines are questions. Texts from other allowed handles need no prefix.
  • No live progress, and approvals are numbered replies. iMessage cannot edit a sent message, so you get 🤔 Working… then the answer, and a tool that needs a human arrives as numbered options you answer with 1 or 2.

4. Let it run in the background

Once it answers, it offers to install itself as a LaunchAgent and hands over. From then on it starts at login and restarts itself if it dies.

jazz imessage status   # installed? running?
jazz imessage logs     # follow it ($JAZZ_HOME/bridge.log)
jazz imessage stop     # stop it

Grant Full Disk Access to the bridge service to limit the permission to that process. Granting it to your terminal also grants access to other commands started there.

Set the allow-list before you accept the install. The plist is written from the environment at install time. IMESSAGE_ALLOWED_HANDLES, JAZZ_HOME, JAZZ_IMESSAGE_MODEL and the rest are snapshotted into it, and it is never overwritten afterwards. To change any of them later: jazz imessage stop, delete ~/Library/LaunchAgents/com.github.lvndry.jazz.imessage.plist, then run jazz imessage --local again with the new values (or edit that file by hand). Provider API keys are deliberately not carried: the agent reads those from the OS keyring itself.

5. Let other people in

IMESSAGE_ALLOWED_HANDLES="+15551234567,friend@icloud.com" jazz imessage --local

Deny-by-default, because this answers on a phone number anyone can text. A message from an unlisted number is logged and never answered. Group chats are admitted by their own chat.db rowid (IMESSAGE_ALLOWED_GROUP_CHAT_IDS): being allowed to DM the agent does not put it in your group chats.

Once any allow-list is set, the jazz self-trigger stops being assumed; set IMESSAGE_SELF_TRIGGER=jazz explicitly to keep texting yourself as well. Started without a terminal (which is what the background service is) and with nothing allowed, the bridge refuses to start rather than answer a number anyone can text.

Full variable table: packages/imessage-bot/README.md. Use Photon if the agent needs its own line.


WhatsApp

The bridge links to your WhatsApp account as a device, exactly as WhatsApp Web does. It runs wherever you are, not in a container, and it has no background installer: keep the process alive yourself (a terminal you leave open, tmux, or your init system of choice).

1. Start it

jazz whatsapp

From a checkout without an installed binary, bun packages/whatsapp-bot/src/main.ts is the same thing. --agent nostra seeds it from an agent you already have, copied into the bridge’s own home.

On the first run it asks whose messages the agent should answer and remembers the answer in wa-allowed.json under its home. To skip the question, which is what you want on a server, where the bridge refuses to start rather than ask a terminal that is not there:

WHATSAPP_ALLOWED_NUMBERS="+15551234567,+33123456789" jazz whatsapp

The environment wins over the saved answer, which wins over asking.

2. Pair it

It prints a QR code: WhatsApp → Settings → Linked Devices → Link a device. Scan the one on screen promptly. WhatsApp expires each code after about a minute and prints another, and a stale one is refused with “check your connection”.

On a machine with no screen to point a phone at, set WHATSAPP_PAIR_NUMBER to the account’s own number and it prints an 8-character code to type in under Link with phone number instead.

Pairing happens once. The linked-device credentials land in WHATSAPP_AUTH_DIR ($JAZZ_HOME/wa-auth by default, where JAZZ_HOME is ~/.jazz-whatsapp): anything that can read that directory can act as the account.

3. Talk to it

DM the linked account from any allowed number. Approvals and questions arrive as numbered options you answer by replying with a number: WhatsApp buttons are restricted to business accounts and degrade to nothing on a personal one.

4. Groups

An allowed group (WHATSAPP_ALLOWED_GROUPS, by JID) still needs the bot @-mentioned or replied to before it answers, so it can sit in a busy thread without joining in. Turn that off with WHATSAPP_REQUIRE_MENTION_IN_GROUPS=0 if you want it answering everything. Being allowed to DM the agent does not admit you to a group, and the reverse is also true, but inside an allowed group the number list is not consulted at all, so everyone in that thread can address the agent.

Group JIDs look like 120363043211234567@g.us. The simplest way to get one is to run the bridge, send a message in the group, and read the line it logs about ignoring a message from a group that is not on the list.

What to know before you rely on it

WhatsApp publishes no API for personal accounts, so this speaks the WhatsApp Web protocol via Baileys: capable, but not sanctioned by Meta. A number that behaves unusually can be rate-limited or banned, so use a dedicated number if the account matters. Unlinking the device from your phone ends the session and the bridge says so and exits. And it is a full device: everything that account receives, this process receives: the allow-list decides what it answers, not what it sees.

Full variable table: packages/whatsapp-bot/README.md.


Keeping a bridge healthy

Every bridge answers /stop to end the answer in progress, and /status, /help and /new straight away even while a run is going. Telegram and Discord serve /health on their PORT; it turns 503 when the bot stops reaching its platform (a revoked token, a poll conflict, a gateway that will not stay up), which is what the compose health check and auto-update.sh’s rollback read. iMessage, WhatsApp and Photon serve the same check when JAZZ_BRIDGE_HEALTH_PORT is set. A stop or restart drains: people with a run in flight are told it was stopped, and Telegram still answers what was sent while it was down.


Operators

Being on an allowlist lets someone talk to the agent. It does not let them widen what the agent may do without asking: /mode yolo (every tool runs unprompted, shell included) and Telegram’s “Always allow” are for operators only. Everyone else who tries is told they cannot, with the id to add if they should.

BridgeOperators
TelegramTELEGRAM_OPERATOR_IDS: comma-separated Telegram user ids
DiscordDISCORD_OPERATOR_IDS: comma-separated Discord user ids
WhatsAppWHATSAPP_OPERATOR_NUMBERS: comma-separated numbers, international form
iMessageIMESSAGE_OPERATOR_HANDLES: numbers or Apple IDs; the account owner always is
PhotonPHOTON_OPERATOR_HANDLES: numbers or Apple IDs

Unset means nobody can turn approvals off from a chat, which is the safe default for a bot several people share. Setting it back with /mode safe is open to anyone.

In a group, an approval belongs to the person whose message started the run: only they can approve, reject or answer it, by button or by replying with the number.


Adding more providers

Every bridge starts on one provider (OPENAI_API_KEY/gpt-5.4 by default), but /model can switch a conversation to any of the 23 providers Jazz supports. Anthropic, Gemini, xAI, OpenRouter, Groq, and more: without touching JAZZ_TELEGRAM_PROVIDER, JAZZ_DISCORD_PROVIDER, JAZZ_IMESSAGE_PROVIDER or JAZZ_WHATSAPP_PROVIDER (those only set what a brand-new conversation starts on).

To enable a provider for /model, set its API key as an env var on the bot and restart the container. .env.example lists the full set (ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, OPENROUTER_API_KEY, XAI_API_KEY, GROQ_API_KEY, …). Then, as a normal message in the chat:

/model openrouter/z-ai/glm-5.3-flash

Bare /model (no arguments) instead shows a picker of whatever the conversation’s current provider offers on Telegram and Discord, which have buttons; iMessage and WhatsApp name the current model and how to switch. Reasoning effort is set automatically either way.


Keeping it updated

The Telegram and Discord bots ship an auto-update.sh that moves the checkout to the newest Jazz release tag (vX.Y.Z) once one is published, rebuilds only if something changed, and rolls back if the new build doesn’t come up healthy. It never moves backwards: a checkout that is already ahead of the newest release (for example one that used to follow main) stays where it is until the next release passes it. Install it as an hourly cron job (adjust the path to where you cloned the repo):

(crontab -l 2>/dev/null; echo "30 * * * * $HOME/jazz/packages/telegram-bot/src/auto-update.sh >> $HOME/jazz-autoupdate.log 2>&1") | crontab -

To run every commit merged to a branch instead of releases, set JAZZ_DEPLOY_BRANCH in the cron entry. The script then fast-forwards to origin/<branch> on every run:

(crontab -l 2>/dev/null; echo "30 * * * * JAZZ_DEPLOY_BRANCH=main $HOME/jazz/packages/telegram-bot/src/auto-update.sh >> $HOME/jazz-autoupdate.log 2>&1") | crontab -

Before moving the checkout, either way, the script stashes tracked edits (untracked files such as a local docker-compose.override.yml or .env are left alone) and names any commits that exist only on that box, so nothing goes quietly missing.

Swap telegram-bot for discord-bot to update the other one. A sibling executable notify.sh (present in both directories) posts the outcome (success, rollback, or a build that needs a look) back to the bot’s own chat/channel, so a failed deploy doesn’t sit silently in a logfile.

machine-readable: /docs/guides/deploy-a-chat-agent.md · /llms.txt · /llms-full.txt