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:
- Send
/newbot. - Pick a display name, then a username ending in
bot(e.g.my_jazz_bot). - BotFather replies with a token that looks like
123456:ABC-DEF.... That’sTELEGRAM_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 yoloor use “Always allow”. See Operators.- A model backend.
OPENAI_API_KEYis set by default (JAZZ_TELEGRAM_PROVIDER=openai,JAZZ_TELEGRAM_MODEL=gpt-5.4). To run fully local instead, setJAZZ_TELEGRAM_PROVIDER=ollamaandJAZZ_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
- Open the Discord developer portal and sign in.
- New Application → name it (e.g.
Jazz) → Create. - Left sidebar → Bot → Reset Token → copy it. That’s
DISCORD_BOT_TOKEN, treat it like a password. - 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.
- 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 only | the channel → Copy Channel ID |
| The whole server | the 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/orDISCORD_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_KEYis set by default (JAZZ_DISCORD_PROVIDER=openai,JAZZ_DISCORD_MODEL=gpt-5.4). To run fully local instead, setJAZZ_DISCORD_PROVIDER=ollamaandJAZZ_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
+, pressCmd-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 with1or2.
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.
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.
| Bridge | Operators |
|---|---|
| Telegram | TELEGRAM_OPERATOR_IDS: comma-separated Telegram user ids |
| Discord | DISCORD_OPERATOR_IDS: comma-separated Discord user ids |
WHATSAPP_OPERATOR_NUMBERS: comma-separated numbers, international form | |
| iMessage | IMESSAGE_OPERATOR_HANDLES: numbers or Apple IDs; the account owner always is |
| Photon | PHOTON_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.