Async Python adapter for connecting bots and AI agents to Zulip chat
Project description
Zulip Agent Adapter
A lightweight async Python library for connecting bots and AI agents to Zulip. It handles the event-loop plumbing — long-polling, reconnection, rate limits, deduplication — so you can focus on your bot's logic.
Status: Alpha (v0.1.5) — used in production with Hermes Agent, but the public API may still change based on feedback.
Features
- Async I/O via
aiohttp— non-blocking event handling - Auto-reconnection with exponential backoff and jitter
- Rate-limit handling (respects Zulip's
retry-afteron 429s) - Sticky topic focus — bot stays engaged in a conversation after @-mention
- Configurable mention gating per channel
- Supports both channel (stream) and direct messages
Installation
From PyPI:
pip install zulip-agent-adapter
For development:
git clone https://github.com/carlh04426/zulip-agent-adapter.git
cd zulip-agent-adapter
pip install -e ".[dev]"
Quick Start
This example shows a simple echo bot that connects to Zulip using explicit config:
import asyncio
from zulip_agent_adapter import ZulipAdapter, ZulipConfig
async def main() -> None:
config = ZulipConfig(
base_url="https://your-org.zulipchat.com",
bot_email="your-bot@your-org.zulipchat.com",
api_key="your-api-key",
chatmode="oncall", # Require @mention to activate
)
adapter = ZulipAdapter(config)
async def on_message(event):
print(f"[{event.source.chat_name}] {event.source.user_name}: {event.text}")
await adapter.send(
event.source.chat_id,
f"Echo: {event.text}",
topic=event.source.chat_topic,
)
adapter.on_message = on_message
connected = await adapter.connect()
if not connected:
raise SystemExit("Failed to connect to Zulip")
print("Connected! Listening for messages... (Ctrl+C to stop)")
try:
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
print("Disconnecting...")
await adapter.disconnect()
if __name__ == "__main__":
asyncio.run(main())
You can also load configuration from environment variables using ZulipConfig.from_env() — see Configuration, immediately below for details.
Configuration
Required Settings
| Setting | Description |
|---|---|
base_url |
Your Zulip server URL (e.g. https://acme.zulipchat.com) |
bot_email |
e.g. urbot134@acme.zulipchat.com |
api_key |
Bot API key from Zulip settings |
Behavior Settings
| Setting | Default | Description |
|---|---|---|
chatmode |
"oncall" |
"oncall" (require mention), "onmessage" (all messages), or "onchar" (accepted, behaves like onmessage — streaming not yet implemented) |
require_mention |
None |
Override mention requirement (None = follow chatmode) |
default_topic |
"general" |
Default topic for channel messages |
streams |
"*" |
Comma-separated channel names to monitor, or "*" for all |
free_response_streams |
"" |
Comma-separated channel IDs that don't require mention |
sticky_topic_after_mention |
None |
Override sticky focus (None = auto-enable for oncall) |
sticky_topic_idle_minutes |
45 |
Minutes before sticky focus expires |
Environment Variables
ZulipConfig.from_env() reads configuration from the environment, which is useful for Docker deployments and CI:
export ZULIP_URL="https://your-org.zulipchat.com"
export ZULIP_BOT_EMAIL="bot@your-org.zulipchat.com"
export ZULIP_API_KEY="your-api-key"
export ZULIP_CHATMODE="oncall" # optional, default: oncall
export ZULIP_STREAMS="general,dev" # optional, default: *
from zulip_agent_adapter import ZulipConfig, ZulipAdapter
config = ZulipConfig.from_env()
adapter = ZulipAdapter(config)
Required variables (ZULIP_URL, ZULIP_BOT_EMAIL, ZULIP_API_KEY) must be set or from_env() will raise KeyError.
Concepts
Chat Modes
oncall(default): Bot only responds when @-mentioned. Good for shared channels.onmessage: Bot responds to every message. Good for dedicated bot channels.onchar: Accepted but currently behaves likeonmessage. Character-level streaming is planned.
Sticky Topic Focus
When enabled (on by default in oncall mode), after a user @-mentions the bot in a channel topic, the bot stays "focused" on that topic for a configurable period (default 45 minutes). During this window it responds to all messages in that topic without requiring additional @-mentions.
Users can clear focus with /unfocus or @**Bot Name** unfocus.
Message Targeting
Channel messages use stream:CHANNEL_ID:
await adapter.send("stream:123", "Hello!", topic="greetings")
Direct messages use private:USER_ID1,USER_ID2:
await adapter.send("private:456,789", "Hello!")
API Reference
ZulipAdapter
The main adapter class.
Methods
async connect() -> bool— Connect to Zulip and start event pollingasync disconnect()— Disconnect and clean up resourcesasync send(chat_id, content, topic=None) -> SendResult— Send a messageasync edit_message(message_id, content) -> SendResult— Edit an existing messageasync get_chat_info(chat_id) -> dict— Returns{"name": str, "type": "channel" | "dm"}
Callbacks
on_message: Callable[[MessageEvent], Awaitable[None]]— Called for each incoming message
MessageEvent
Event object passed to on_message:
| Attribute | Type | Description |
|---|---|---|
text |
str |
Message text (bot mentions stripped in oncall mode) |
message_type |
MessageType |
TEXT or COMMAND (starts with /) |
source |
MessageSource |
Chat and user context (see below) |
message_id |
str |
Zulip message ID |
raw_message |
dict |
Full Zulip event payload for advanced use |
reply_to_message_id |
Optional[str] |
ID of the triggering message |
MessageSource
| Attribute | Type | Description |
|---|---|---|
chat_id |
str |
Routing ID ("stream:123" or "private:456") |
chat_name |
str |
Human-readable channel or DM label |
chat_type |
str |
"channel" or "dm" |
user_id |
Optional[str] |
Sender's Zulip user ID |
user_name |
str |
Sender's display name |
thread_id |
Optional[str] |
Topic name (channels only) |
chat_topic |
Optional[str] |
Same as thread_id; convenience alias |
ZulipConfig
See Configuration above. Also provides ZulipConfig.from_env().
Testing
# Run all tests
pytest
# With coverage
pytest --cov=zulip_agent_adapter --cov-report=html
# Specific test file
pytest tests/test_adapter.py -v
Contributing
This adapter was originally developed as part of an internal Hermes-style agent gateway and then refactored into a standalone package.
If you’d like to contribute:
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-change - Make your changes
- Run the checks:
pytest,black src tests,ruff check src tests,mypy src - Commit and push
- Open a Pull Request
If you're unsure about a change, feel free to open an issue to discuss it first.
License
MIT License — see LICENSE.
Acknowledgments
- Inspired by the OpenClaw gateway architecture.
- Originally developed for a Hermes-style agent gateway and extracted into this standalone adapter.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file zulip_agent_adapter-0.1.5.tar.gz.
File metadata
- Download URL: zulip_agent_adapter-0.1.5.tar.gz
- Upload date:
- Size: 18.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bc2ef8b27b07d9a57355713318bebbc38dedadaf117272aa4d280e0ad008beb9
|
|
| MD5 |
89b61e5e91625e8f6557372a5f4035a9
|
|
| BLAKE2b-256 |
178c8b118187de8d2fe71a85144801d487416ee2febf601cfdd81852040cd109
|
File details
Details for the file zulip_agent_adapter-0.1.5-py3-none-any.whl.
File metadata
- Download URL: zulip_agent_adapter-0.1.5-py3-none-any.whl
- Upload date:
- Size: 15.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bb5f7428d6b07c5047032813af23c37da39c07a62c3de76ee95d6e7bb940b93b
|
|
| MD5 |
38305fa2875ed5146f3b58db9cf53f6e
|
|
| BLAKE2b-256 |
0c51143a17fd50ab606e53c4a5992d41ffb301cf01821baaf076f80219458716
|