Connect your Hermes AI agent to Zulip. Chat with Hermes via streams (with automatic topic threading) or DMs. Supports admin commands, secure DM policies, file uploads, and health monitoring.
💡 What this does: Your Zulip bot becomes a doorway to your Hermes AI. Users type in Zulip, the AI thinks, the bot replies — all while keeping conversations threaded by topic.
pip install "zulip>=0.9.0"
⚠️ Hermes doesn't auto-install plugin dependencies. Run this once in the same Python environment as Hermes.
mkdir -p ~/.hermes/plugins
rm -rf ~/.hermes/plugins/zulip
git clone https://github.com/niyazmft/zulip-hermes-integration.git ~/.hermes/plugins/zulip
hermes plugins enable zulipAdd to ~/.hermes/.env:
ZULIP_API_KEY=your-bot-api-key
ZULIP_EMAIL=your-bot@niyaz.zulipchat.com
ZULIP_SITE=https://niyaz.zulipchat.comThen add to ~/.hermes/config.yaml:
gateway:
platforms:
zulip:
enabled: truehermes gatewaySend a DM or @-mention your bot in a subscribed stream. Done! 🎉
For detailed setup, see docs/SETUP.md.
For admin configuration, see the Environment Variables section below.
| Feature | What it does |
|---|---|
| 💬 Streams + DMs | Talk to the bot in public streams (with topic threading) or private messages |
| 🤔 "Thinking..." placeholder | Bot shows it's working, then edits with the final answer. No awkward silence. |
| 📎 File uploads | Send CSVs, PDFs, JSON — the bot downloads and can process them |
| 🏓 Admin commands | Type /help, /status, /model, /streams, /user, /pin, /unpin for instant responses (no LLM call needed) |
| Feature | What it does |
|---|---|
| 🔐 DM Policies | Control who can DM: open, allowlist, pairing (code-based onboarding), or disabled |
| 🚦 Rate limiting | Per-sender sliding-window rate limiter (default 60 msg/min) prevents message floods |
| 📋 Audit logging | Persistent JSON-line audit log with rotation for security forensics |
| 🩺 Health probe | Pre-flight server check with SSRF protection + structured health_status logging |
| 🛡️ Security hardening | SSRF validation, symlink rejection (O_NOFOLLOW), path traversal blocking, TOCTOU-free file ops |
| ⚡ Performance caching | LRU client + target caches + connection pooling (10 connections, retry on 5xx) |
| 📊 Context metadata | Every message carries conversation_turn, session_gap_seconds, topic_changed to help the AI avoid stale responses |
| 🔄 One-command updates | bash ~/.hermes/plugins/zulip/update.sh pulls latest and restarts |
| Feature | What it does |
|---|---|
| 🔌 Pure plugin | Zero changes to Hermes core. Drop in, enable, done. |
| 🧩 Extensible commands | Add custom bot commands with @register_command decorator |
| 📁 Sandboxed workspace | Bot can generate files (reports, JSON, CSV) in a temp workspace with auto-cleanup |
| 🧪 CI-tested | 431 tests, pre-push hooks, GitHub Actions branch protection |
Type these in any stream or DM. They're handled instantly — no LLM call:
| Command | Response |
|---|---|
/help |
List all available commands |
/status |
Bot version, repo URL, your email |
/model |
Current model status |
/streams |
List streams (or ask AI for management) |
/user |
Get user info (or ask AI) |
/pin |
Star/pin a message (or ask AI) |
/unpin |
Unstar/unpin a message (or ask AI) |
Add your own:
from zulip.commands import register_command
@register_command("ping")
def _cmd_ping(args, chat_id, sender_email, sender_name):
return "🏓 Pong!"Set ZULIP_DM_POLICY to control who can message the bot:
| Mode | Behavior | Use case |
|---|---|---|
open (default) |
Anyone can DM | Small teams, public bots |
allowlist |
Only ZULIP_ALLOWED_USERS can DM |
Internal team bots |
pairing |
New users get a pairing code to share with an admin | Moderated onboarding |
disabled |
All DMs blocked | Stream-only bots |
Pairing mode flow:
New user DM → "Your pairing code: PAIR-ABC123"
Admin approves → user can DM normally
The bot can generate and send files as Zulip uploads:
from zulip.workspace import BotWorkspace
ws = BotWorkspace()
path = ws.save_text("report.csv", "id,value\n1,42\n")
await adapter.send(
chat_id="dm:42",
content="Here is your report:",
media_files=[path]
)Files appear as clickable links. Temp files auto-delete after upload. Path traversal and symlinks are rejected.
Zulip Stream/DM
↓
ZulipAdapter._listen_for_events() # Event queue long-polling
↓
MessageEvent (with topic metadata + context fields)
↓
Gateway session → AI Agent
↓
ZulipAdapter.send() → Zulip REST API
All synchronous SDK calls are wrapped with asyncio.to_thread() to keep the gateway event loop responsive.
| Variable | Example | Description |
|---|---|---|
ZULIP_API_KEY |
abcd1234... |
Bot API key from Zulip settings |
ZULIP_EMAIL |
bot@company.zulipchat.com |
Bot email address |
ZULIP_SITE |
https://company.zulipchat.com |
Your Zulip organization URL |
| Variable | Default | Description |
|---|---|---|
ZULIP_ALLOWED_USERS |
(empty) | Comma-separated emails allowed to DM |
ZULIP_DM_POLICY |
open |
open / allowlist / pairing / disabled |
ZULIP_GROUP_POLICY |
open |
Group/stream policy: open / allowlist / disabled |
ZULIP_GROUP_ALLOW_FROM |
(empty) | Comma-separated emails allowed for stream messages |
ZULIP_MAX_MESSAGES_PER_MINUTE |
60 |
Per-sender rate limit (0 to disable) |
| Variable | Default | Description |
|---|---|---|
ZULIP_CHATMODE |
onmessage |
Stream trigger: onmessage / oncall / onchar |
ZULIP_REQUIRE_MENTION |
true |
Stream messages need @mention (except onmessage) |
ZULIP_EDIT_PLACEHOLDER |
true |
Show "Thinking..." placeholder while AI generates |
ZULIP_REACTIONS_ENABLED |
true |
Emoji reactions (👀/✅/ |
ZULIP_CHUNK_LIMIT |
4000 |
Max chars per message chunk |
ZULIP_TOPIC_SESSIONS |
false |
Per-topic conversation sessions (opt-in) |
ZULIP_DM_SESSION_TURN_LIMIT |
20 |
DM session rotation after N turns (0 to disable) |
ZULIP_TYPING_DELAY_SECONDS |
2.0 |
Typing indicator delay after send |
ZULIP_STREAMS |
* |
Comma-separated stream names to monitor |
ZULIP_RESPONSE_PREFIX |
(empty) | Prepended to every outbound message |
ZULIP_STREAM_OVERRIDES |
(empty) | JSON object mapping stream names to per-stream chatmode overrides |
In oncall and onchar modes the bot only replies when mentioned, so getting
this right matters.
Detection prefers Zulip's own mentioned flag, which the server sets for a
personal mention regardless of which markup the sender used. Text matching is
only a fallback for events that arrive without flags, and it recognises:
| Form | Where it comes from |
|---|---|
@Soju |
what @**Soju** becomes after inbound HTML/markdown stripping |
@**Soju** |
raw Zulip mention markup |
@_**Soju** |
silent mention |
@**Soju|12** |
mention disambiguated by user id |
@soju-bot |
hand-typed email local-part |
Both the bot's display name and its email local-part are matched, because Zulip writes mentions from the display name while the account is identified by the local-part.
| Variable | Default | Description |
|---|---|---|
ZULIP_CHUNK_MODE |
length |
Chunking strategy: length or newline |
ZULIP_ONCHAR_PREFIXES |
!,> |
Custom onchar triggers |
ZULIP_BLOCK_STREAMING |
false |
Experimental block streaming |
ZULIP_MEDIA_MAX_MB |
5 |
Max inbound attachment size (MB) |
ZULIP_ALLOW_ALL_USERS |
false |
Disable all authorization (dev only) |
ZULIP_CONNECT_TIMEOUT |
30 |
Connection timeout (seconds) |
ZULIP_READ_TIMEOUT |
60 |
Read timeout (seconds) |
ZULIP_SEND_TIMEOUT |
90 |
Send timeout (seconds) |
| Problem | Fix |
|---|---|
| "zulip package not installed" | Run pip install "zulip>=0.9.0" in Hermes's Python env |
| "No adapter available for zulip" | Check logs for syntax errors; verify plugin.yaml is present |
| Bot not responding in streams | Bot must be subscribed to the stream in Zulip settings |
| "Invalid or unsafe ZULIP_SITE" | Use https:// URL, not localhost or IP addresses |
| Setup wizard shows instructions only | Ensure setup_fn=interactive_setup is passed to register() |
For detailed agent instructions, see AGENTS.md.
# One-command update (downloads latest + restarts Hermes)
ssh user@device "bash ~/.hermes/plugins/zulip/update.sh"Or manually:
cd ~/.hermes/plugins/zulip
git pull origin main
hermes gateway restart# 1. Fork and clone
git clone https://github.com/YOU/zulip-hermes-integration.git
cd zulip-hermes-integration
# 2. Install hooks
bash scripts/setup-hooks.sh
# 3. Make changes
# ...
# 4. Run checks
bash .githooks/pre-push
# 5. Submit PR (squash merge, branch protection enforced)- 431 tests — run via
pytest tests/ - Pre-push hook — runs syntax checks + tests before every push
- CI — GitHub Actions
zulip-bridgejob must pass before merge - Branch protection — requires PR + linear history + squash merge
MIT License — see LICENSE.