Skip to main content

Async Python adapter for connecting bots and AI agents to Zulip chat

Project description

Zulip Agent Adapter

Python 3.9+ License: MIT Code style: black

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-after on 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 like onmessage. 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 polling
  • async disconnect() — Disconnect and clean up resources
  • async send(chat_id, content, topic=None) -> SendResult — Send a message
  • async edit_message(message_id, content) -> SendResult — Edit an existing message
  • async 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

zulip_agent_adapter-0.1.5.tar.gz (18.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

zulip_agent_adapter-0.1.5-py3-none-any.whl (15.9 kB view details)

Uploaded Python 3

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

Hashes for zulip_agent_adapter-0.1.5.tar.gz
Algorithm Hash digest
SHA256 bc2ef8b27b07d9a57355713318bebbc38dedadaf117272aa4d280e0ad008beb9
MD5 89b61e5e91625e8f6557372a5f4035a9
BLAKE2b-256 178c8b118187de8d2fe71a85144801d487416ee2febf601cfdd81852040cd109

See more details on using hashes here.

File details

Details for the file zulip_agent_adapter-0.1.5-py3-none-any.whl.

File metadata

File hashes

Hashes for zulip_agent_adapter-0.1.5-py3-none-any.whl
Algorithm Hash digest
SHA256 bb5f7428d6b07c5047032813af23c37da39c07a62c3de76ee95d6e7bb940b93b
MD5 38305fa2875ed5146f3b58db9cf53f6e
BLAKE2b-256 0c51143a17fd50ab606e53c4a5992d41ffb301cf01821baaf076f80219458716

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page