Install
openclaw plugins install clawhub:@niyazmft/openclaw-zulipopenclaw plugins install clawhub:@niyazmft/openclaw-zulipHigh-performance OpenClaw channel plugin for Zulip streams and private messages with persistent event queues, traffic policies, and comprehensive observability.
๐ Part of a single Zulip adapter family for open-source AI agents. This repo is the OpenClaw adapter (TypeScript). Its sibling,
zulip-hermes-integration, does the same thing for the Hermes (Nous Research) agent (Python). Same thesis, two runtimes: bring a self-hosted AI agent into threaded, topic-first Zulip chat as a full teammate โ sovereign, no chat-vendor lock-in. The pattern Slack and Block's Buzz are racing to productize, delivered open source and self-hosted.
# 1. Install from ClawHub
openclaw plugins install clawhub:@niyazmft/openclaw-zulip
# 2. Restart the gateway
openclaw gateway restart
# 3. Run the interactive setup wizard
openclaw channels add
# โ Select "Zulip (plugin)" โ enter API key, email, URL โ route to agent
# 4. Approve yourself for DMs (dmPolicy defaults to "pairing")
# DM the bot first; it replies with a pairing code, then approve it:
openclaw pairing list zulip # pending requests
openclaw pairing approve zulip <code> # <code> alone works if one pairing channel is configured
# 5. Test
# Send a DM to your bot or mention it in a stream
oncall, onmessage, onchar modes)upload-file action or reference sandboxed local paths[[zulip_ref: โฆ]] markers that the plugin validates against the GitHub API and renders as clickable links (opt-in){dataDir}/workspace/allowInsecureHttp is opted in>=2026.7.1See AGENTS.md for Node 24 / CJS Gateway host compatibility notes and runtime requirements.
openclaw plugins install clawhub:@niyazmft/openclaw-zulip
Then restart the gateway and run openclaw channels add โ see Quick Start for the full sequence.
See CONTRIBUTING.md for development setup instructions.
Run openclaw channels add and select "Zulip (plugin)". The wizard will guide you through:
https://your-org.zulipchat.com)pairing (default), open, allowlist or disabledEdit ~/.openclaw/openclaw.json:
{
"channels": {
"zulip": {
"accounts": [
{
"id": "default",
"siteUrl": "https://your-org.zulipchat.com",
"apiKey": "your-api-key",
"email": "your-bot@your-org.zulipchat.com"
}
],
"streams": ["general", "dev"],
"chatmode": "onmessage",
"dmPolicy": "pairing",
"groupAllowFrom": ["you@your-org.zulipchat.com"]
}
}
}
streams and chatmode only decide when the bot answers: groupPolicy defaults to "allowlist", so stream senders must also be named in groupAllowFrom (or allowFrom) or stream messages are dropped.
| Option | Type | Default | Description |
|---|---|---|---|
url | string | required | Zulip realm URL (site and realm are accepted aliases) |
apiKey | string | required | Bot API key |
email | string | required | Bot email address |
streams | string[] | ["*"] | Streams to monitor ("*" = all public streams) |
chatmode | "oncall" | "onmessage" | "onchar" | โ | When the bot responds in streams: oncall needs a mention, onmessage replies to every message, onchar needs a prefix from oncharPrefixes. Unset leaves it to requireMention |
requireMention | boolean | true | Require an @mention in streams; chatmode overrides it |
oncharPrefixes | string[] | [">", "!"] | Prefixes that trigger chatmode: "onchar" |
dmPolicy | "open" | "pairing" | "allowlist" | "disabled" | "pairing" | Who can DM the bot ("open" requires allowFrom to include "*") |
allowFrom | string[] | [] | Allowed DM senders (bot email or numeric user id; only static config may contain "*") |
groupPolicy | "allowlist" | "open" | "disabled" | "allowlist" | Who can trigger the bot in streams |
groupAllowFrom | string[] | [] | Allowed stream senders (falls back to allowFrom when empty) |
allowInsecureHttp | boolean | false | Allow a plain http:// site URL and private/internal addresses |
mediaMaxMb | number | 5 | Max inbound attachment size in MB |
enableAdminActions | boolean | false | Enable destructive admin actions |
maxMessagesPerMinute | number | 60 | Rate limit per sender (0 disables) |
showThinkingPlaceholder | boolean | false | Show "Thinking..." placeholder |
autoSendOnMissingTool | boolean | true | Send the run's answer even if the agent never called the messaging tool |
responsePrefix | string | โ | Prefix prepended to every reply |
maxMessageLength | number | 20000 | Max outbound message length (0 disables) |
textChunkLimit | number | 4000 | Outbound chunk size in characters |
chunkMode | "length" | "newline" | "length" | Split long replies by size, or on every newline |
dmSessionTurnLimit | number | 20 | Inbound turns in one DM session before a fresh session starts (0 disables) |
enableSessionRecovery | boolean | false | Recover interrupted messages |
sessionArchiveRepair | boolean | unset (automatic) | Publish stuck session transcript archives; true always, false never |
blockSecretLeaks | boolean | true | Block messages containing credentials |
activityTrace | boolean | false | Post one live, in-place-edited status message per work item |
traceCoalesceMs | number | 400 | Coalescing window for trace edits (ms), clamped 0โ60000 |
traceMaxRate | number | 2 | Hard ceiling on trace edits per second, clamped 0.1โ50 |
historyContext | "off" | "on-demand" | "always" | "off" | Harvest bounded past stream/topic history into context |
historyMaxMessages | number | 8 | Max earlier messages injected as history (clamped 1โ50) |
historyWindowHours | number | 72 | How far back history is considered (clamped 1โ8760) |
historyMaxChars | number | 4000 | Hard cap on the rendered history block (clamped 200โ20000) |
renderRefs | boolean | false | Validate [[zulip_ref: โฆ]] markers and render them as links |
reactionTriggers | object | โ | Emoji โ instruction map; a reaction then dispatches that instruction in-thread |
reactionTriggerAnyMessage | boolean | false | Allow triggers on messages the bot did not author |
queueMode | "off" | "followup" | "off" | Hold a message arriving mid-run until that session's active run finishes |
queueCap | number | 20 | Max messages waiting behind a run, clamped 1โ500 (past it, dispatch immediately) |
reactions.enabled | boolean | true | Use status reactions at all |
reactions.onStart | string | "eyes" | Reaction while a run is in progress |
reactions.onSuccess | string | "check_mark" | Reaction when a run succeeds |
reactions.onError | string | "warning" | Reaction when a run fails |
reactions.onQueued | string | "hourglass" | Emoji added to a message waiting in the queue |
reactions.clearOnFinish | boolean | true | Remove the status reaction when the run ends |
Accepted for account plumbing, or owned by the host. Valid in both channels.zulip and channels.zulip.accounts.<id>.
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Start this account (false skips it) |
name | string | โ | Display name in CLI/UI account lists |
capabilities | string[] | โ | Provider capability tags for agent/runtime guidance |
configWrites | boolean | true | Allow channel-initiated config writes |
streaming | boolean | host default | Host reply-streaming mode (written by openclaw channels add) |
markdown | object | host default | Host markdown formatting options |
blockStreaming | boolean | host default | false disables block streaming for this account |
blockStreamingCoalesce | object | host default | Merge streamed block replies before sending |
Credentials and the site URL can be set through environment variables instead of config. They take precedence over config for the default account only โ non-default accounts are config-only.
| Variable | Purpose |
|---|---|
ZULIP_API_KEY | Bot API key |
ZULIP_EMAIL | Bot email |
ZULIP_URL | Zulip realm URL |
ZULIP_SITE | Alternative URL variable |
ZULIP_REALM | Realm name |
ZULIP_ALLOW_INSECURE_HTTP | Allow plain HTTP and private/internal addresses (default account only) |
Send a test message to verify the bot is responding:
# In a stream (if chatmode allows)
@**bot-name** hello
# Or in a DM
hello bot
The bot should respond with a helpful message. Check openclaw logs if it doesn't.
The agent can attach files to replies in one step using the core message tool's upload-file action. The host resolves media (a local path or an http(s) URL) into the file bytes:
{
"type": "action",
"name": "upload-file",
"params": {
"to": "stream:general:deploys",
"media": "/path/to/report.pdf",
"caption": "Here is the report"
}
}
Or reference sandboxed local paths from a send action's media field:
{
"type": "action",
"name": "send",
"params": {
"to": "stream:general:deploys",
"message": "Here is the image",
"media": ["workspace/image.png"]
}
}
upload-file stages the bytes in the bot workspace ({dataDir}/workspace/, path-traversal rejected, pruned after an hour) before uploading. Attachments are read only from the system temp directory, the data dir or the bot workspace, symlinks are refused, and config, credential and session files are refused explicitly even though they sit under the data dir. A relative path (e.g. workspace/image.png) is tried against the agent workspace, then the data dir, then the process working directory.
By default the room only sees the agent's final reply, so a run that takes a while (or ends
without a reply at all) is invisible. With activityTrace: true, the plugin keeps one
dedicated bot-owned status message per work item and edits it in place as the run progresses:
zulip-bot ยท **Working** โ fix the failing auth test
- โ
$ git status --short (0.2s)
- โ
$ npm test (12s)
- ๐ฌ switching to a rebase instead of a merge
- โณ $ git push
When the run ends, the block collapses to one compact line (โ
**Done** โ run finished in 18s).
The message is never deleted, so the topic keeps an audit trail (Zulip also keeps its own edit
history). If the gateway restarts mid-run (deploy, crash, OOM), the next start closes that trace out
as โช **Cancelled** โ run interrupted by a gateway restart, so a topic never keeps a stale
"Working" line.
Status detail edits the trace. Actionable results are posted as new messages. That keeps the topic readable while still producing a durable record, instead of one message per tool call.
| Mode | Source | Notes |
|---|---|---|
| A โ plugin-driven | The host's after_tool_call hook, filtered to exec | Automatic; no agent cooperation needed. Best-effort per runtime/harness. |
| B โ agent-driven | The zulip_progress tool | Lets the agent narrate intent the plugin cannot infer ("about to ask a clarifying question", "switching approach"). No-op when no trace is active. |
Mode A never registers before_tool_call (it is a fail-closed gate that could block the agent's own
tool call). Mode B is unaffected when the host's tool profile strips plugin tools โ the run-boundary
trace still appears.
Zulip edits are ~600ms round-trips, so the trace is a status board, not a metronome:
traceCoalesceMs (default 400) collapses bursty updates into a single edit.traceMaxRate (default 2) is a hard ceiling on edits per second, in addition to coalescing.Tracing is best-effort and never blocking. A failed post drops that trace; a failed edit is logged and dropped โ there is no retry loop (unbounded retries against Zulip are how you flood the gateway log) and no user-visible error. A dead trace can never turn a successful reply into a failed dispatch. Trace writes are also credential-redacted, because trace edits do not pass through the normal outbound secret guard.
activityTrace defaults to off: the feature adds outbound writes, so it ships opt-in. With it
off, behaviour is identical to a build without it.
The plugin's only durable record of a topic is Zulip itself, but by default the agent sees just the current message plus whatever survived in its own runtime memory โ so "have we seen this error before?" gets answered from vibes rather than the team's actual history.
With historyContext enabled, the bridge harvests a bounded slice of the current stream/topic
and adds it to the agent's prompt as evidence:
[Zulip history โ 3 earlier message(s) in #main / deploys]
- Dana (3d ago): same 502 on the auth service, it was the connection pool limit
- Bot (3d ago): raised max_connections to 50 in commit 4f2c1ab
- Niyaz (1h ago): it is back after the config revert
[end history]
off (default) โ never harvest.on-demand โ only when the message looks like a "do we know this?" question (the intent
patterns are deliberately narrow). Recommended: a harvest is one extra Zulip round-trip (~600ms)
on the reply path and costs context budget.always โ every inbound stream message carries the block.Bounded on every axis. historyMaxMessages (8), historyWindowHours (72) and
historyMaxChars (4000) cap the block, and the selection keeps the newest lines, so a topic with
months of history can never blow up the context window.
Best-effort. A slow or failing harvest is logged and dropped โ it can never fail a dispatch โ and it is wrapped in a 2s timeout so a retrying API call cannot stall a reply.
Streams/topics only. This applies to stream messages: DMs keep their per-user session continuity and strict isolation, so harvesting them would add privacy surface for little gain. The block is appended to the agent-facing prompt only; commands are unaffected.
"I opened a PR" is readable but not actionable. With renderRefs: true, the agent can emit a
structured marker and the plugin turns it into a validated, clickable link:
Shipped it in [[zulip_ref: https://github.com/owner/repo/pull/128 | PR #128]] โ CI is
[[zulip_ref: https://github.com/owner/repo/actions/runs/12345 | green]].
becomes
Shipped it in [PR #128](https://github.com/owner/repo/pull/128) โ CI is [green](https://github.com/owner/repo/actions/runs/12345).
Validation is real, not cosmetic. Each ref is checked against the GitHub API before it is rendered as a link, so the reply is evidence rather than a claim:
| Ref | Validated as |
|---|---|
github.com/<owner>/<repo>/pull/<n> | a pull request |
github.com/<owner>/<repo>/issues/<n> | an issue |
github.com/<owner>/<repo>/commit/<sha> | a commit |
github.com/<owner>/<repo>/actions/runs/<id> | an Actions run |
Anything that cannot be confirmed โ a malformed URL, a 404, a rate limit, a timeout, a network
error โ renders as plain text (in backticks, so it is not auto-linked) and the reply still
sends. A label is optional; without one, the plugin derives one (owner/repo#128).
Safety properties worth knowing:
https://github.com/... refs are handled. Anything else (including internal/private
hosts and lookalike domains like github.com.evil.com) is rejected before any network request,
and the API origin is a hardcoded https://api.github.com โ there is no configurable host that
could be widened into an SSRF primitive.renderRefs defaults to off, because it changes how agent prose is interpreted.
The bridge is otherwise read/send only: a human can reply, but cannot say "go" from inside the topic
and have the agent act there. With reactionTriggers, a reaction becomes an action:
{
"channels": {
"zulip": {
"reactionTriggers": {
"+1": "Proceed with the proposed step.",
"check": "Ship it and open the PR."
}
}
}
}
When an authorised user reacts ๐ on the bot's own message in a monitored stream, the mapped instruction is dispatched as a normal turn for that same stream/topic session โ so the agent acts where the discussion already is, and its reply (and activity trace) land in the same topic.
A reaction is a trigger, not an authorisation bypass. The synthetic turn carries the reacting
human as its sender, so every existing decision is made about them: dmPolicy/groupPolicy, the
static and persisted allowlists, the control-command gate and the per-sender rate limit all still
apply. A stranger's reaction does nothing.
Safety rules:
reactionTriggerAnyMessage: true deliberately if you want that.reaction events only to
subscribers of the stream, while message events arrive anyway (the queue uses
all_public_streams) โ so an unsubscribed stream fails silently: the trigger simply never fires.
The plugin checks at startup and warns with the streams you are missing (or, with streams: ["*"],
prints the subscribed list so you can compare). To fix it, subscribe the bot in Zulip's
stream settings, or POST /api/v1/users/me/subscriptions.reaction_trigger audit event.reactionTriggers map, no trigger emoji is recognised and the
reaction event type is not even requested from Zulip.What this deliberately is not: it does not launch arbitrary named workflows or scripts. The trigger is an instruction to the agent that is already in this conversation, so its blast radius is the same as someone typing that sentence.
Emoji names are the Zulip API names, not glyphs: ๐ is +1, ๐ is eyes. A name that does not
match a Zulip emoji name never fires.
Work through these in order โ the plugin logs (or audit-logs) each stage:
GET /api/v1/users/me/subscriptions.reactionTriggers? The map is keyed by the Zulip emoji name (+1, check, โฆ).reaction_trigger event to
{dataDir}/audit/{accountId}.audit.log, so the audit log tells you whether the plugin saw it.reactionTriggerAnyMessage: true.user_id (the event
carries no user object) and drops the trigger with a warning if that fails, so a broken sender
never reaches the allowlist as an un-authorizable numeric id.If you cannot see any of that: plugin child-logger output does not necessarily reach the host
log โ on Termux the gateway log contained zero plugin lines while the host's own lines were all
present. Use the audit log ({dataDir}/audit/{accountId}.audit.log, which records
reaction_trigger and reaction_trigger_subscription_gap) and the Zulip API for verification
instead of assuming the plugin is silent.
A Zulip topic is one conversation: everyone in it shares one session, so the bot works one request
at a time there. What happens to a second request that arrives mid-run is the host's decision, and
the default is not friendly to a shared room โ steer pushes the new message into the running
turn, so teammate B can redirect teammate A's work.
The host can be configured to wait instead (messages.queue.mode: "followup"), but:
messages.queue.byChannel.zulip) is rejected โ byChannel accepts only
known/bundled channel ids, and Zulip is a third-party plugin channel (Unrecognized key: "zulip");messages.queue.mode would also
change your other channels (e.g. Telegram).So the plugin queues for itself, Zulip-only (queueMode: "followup"):
{ "channels": { "zulip": { "queueMode": "followup" } } }
reactions.onQueued defaults to the
Zulip emoji name hourglass โ since Zulip has no "queued input" surface. ๐ still means received
and โ
still means finished.queueCap a message is dispatched immediately rather than dropped.message_queued / message_dequeued (with the message id and
how many were waiting): the reaction is transient โ it disappears the moment the turn starts โ and
plugin logs do not surface on every host, so the audit file is the durable proof that the queue
engaged.For genuinely parallel work use separate topics; for private work use DMs (per-user sessions).
Cause: The plugin was installed but "zulip" is not in plugins.allow.
Fix:
openclaw config set plugins.allow '["zulip","telegram","memory-core","exa","ollama"]'
openclaw gateway restart
Install from ClawHub:
openclaw plugins install clawhub:@niyazmft/openclaw-zulip
Or from source without --link (this deletes the repo's own build and check scripts, so work in a
copy or restore them afterwards):
rm -rf scripts/
openclaw plugins install ./ --force
openclaw gateway restartls ~/.openclaw/extensions/zulip/Check three things: the bot is subscribed to the stream and streams includes it (or is ["*"]);
the sender is named in groupAllowFrom (or allowFrom), because the default groupPolicy is
"allowlist" and an empty allowlist drops every stream message; and the message satisfies the
mention rule for your chatmode.
Streams require an @mention unless chatmode: "onmessage" (or requireMention: false) is set.
For everything else โ health-monitor restarts, duplicate registerFull calls, typing indicators,
fallback reader, humanDelay, status message leaks, session conflation, the Node 24 / ESM race, and
the rest โ see AGENTS.md#troubleshooting.
MIT License - see LICENSE file for details.