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.