Zulip
Zulip channel plugin for OpenClaw — concurrent message processing, file uploads, approval hooks, and native message actions
Install
openclaw plugins install clawhub:@ftlc-ian/openclaw-channel-zulipopenclaw-channel-zulip
Setting actionTiers.messages: "off" blocks shared/core outbound text, media, payload and poll delivery before credential resolution (including core-routed/scheduled sends). Normal inbound Zulip replies and lifecycle/progress messages use the internal reply path and remain enabled. The supported SDK provides no trusted tool-origin flag on outbound sends, so there is no payload-based bypass.
Local-file upload security
Local uploads require host media roots or an explicit absolute allowlist:
{ "channels": { "zulip": { "media": { "localRoots": ["/absolute/export-directory"] } } } }
The same media object works per account. Roots are additive with host roots; none means fail closed. Canonical paths prevent traversal and symlink escapes. media.localUploads defaults to true within those roots; set false to disable local uploads entirely. Local uploads reuse mediaMaxMb (account, agent default, then 5 MB). Zulip-hosted and remote HTTP(S) URL behavior is unchanged. See local upload policy and upgrade guidance.
Features
- ✅ Concurrent message processing — events fire-and-forget with staggered start times (200 ms apart), so a burst of incoming messages is handled in parallel rather than queued sequentially
- ✅ Native session conversation binding — stream topics resolve through the SDK session-conversation hook instead of hand-rolled session key grammar
- ✅ File uploads — inbound Zulip file attachments are downloaded and forwarded to the AI pipeline; outbound media is uploaded via Zulip's file upload API
- ✅ Native message actions — send, read, search, react, edit, delete/unsend, polls, member lookup, and stream list/create/edit/delete with an audited capability matrix; per-account
actionTiersopt in to moderation actions (message edit/delete/unsend, member lookup) and stream create/edit/delete (streamAdmin) - ✅ Topic directives — reply topics can be scoped per-message, enabling organized thread-based conversations
- ✅ Multi-account support — run multiple Zulip bot accounts in one OpenClaw instance via the
accountsmap - ✅ DM & channel policies — open / pairing / allowlist / disabled per account
- ✅ Block streaming — real-time streaming replies with configurable coalescing (min chars / idle timeout)
- ✅ Onboarding wizard —
openclaw onboardwalks you through setup interactively
Opt-in progress and native questions
Progress is disabled unless channels.zulip.streaming.mode (or the selected
account's mode) is explicitly "progress". Merely setting streaming.progress
options does not enable it. The public thinkingPlaceholder schema remains supported.
| Configuration | Behavior |
|---|---|
| Neither enabled / progress off | No progress draft or legacy placeholder |
| Legacy only, including explicit progress off | Legacy text-only final replaces the placeholder; media/questions delete it first |
| Explicit progress only | One evolving run-local draft, deleted before normal final/question delivery |
| Both enabled | Progress takes precedence; no legacy placeholder or legacy error message |
Opt in with "streaming": { "mode": "progress", "progress": { "toolProgress": true, "narration": true, "commandText": "status" } }. Progress uses a four-line default
summary and coalesces updates with per-run mutation spacing. Cancellation and
errors close queued/in-flight progress work and attempt bounded cleanup. Permanent
edit failure permits one replacement only after successful deletion; persistent
delete failure can leave residue. This is not account-wide rate limiting.
Legacy errors retain configured errorText; silent/cancelled turns remove the
legacy placeholder. These behaviors do not imply guaranteed provider cleanup.
Native questions support canonical single-choice desktop controls and constrained mobile numeric/text/list fallback. Controls are bound to account, sender and actual sent conversation, and intercepted before ordinary dispatch. Terminal replacement is sent before deleting the widget; failed deletion rolls back the replacement where possible. Bindings are bounded and in memory: expiry/restart do not guarantee visual cleanup or persistent recovery.
Installation
This release requires OpenClaw >=2026.9.3 and **Node.js >=24.16.0 <25 or
=26.1.0**. The development SDK and lockfile are pinned to OpenClaw 2026.9.6. The minimum host remains 2026.9.3 because every SDK subpath used by the plugin, including its asynchronous outbound session-routing hook, is public in that version.
Durable inbound handling uses the shared ingress queue API. When upgrading an existing installation, pending records and deduplication tombstones from the older keyed-store journal retain their original namespaces and retention. They are replayed or completed through the compatibility journal instead of being silently discarded.
Outbound media loading uses the typed media-runtime SDK surface.
Command access-group authorization remains enabled even if an older
configuration still contains the removed commands.useAccessGroups toggle.
Do not install this release on an older host or unsupported Node version. Validate host and runtime upgrades separately in an isolated environment before changing production.
First install from npm (recommended)
For a new installation, use this project's npm package:
openclaw plugins install openclaw-channel-zulip
For an existing tracked npm installation, use openclaw plugins update zulip
instead of repeating the install. The npm package name is openclaw-channel-zulip;
the runtime plugin id is zulip. plugins install has no --wait option.
The project's ClawHub listing is an install/setup skill that points to this npm package, not a separately installable ClawHub plugin artifact. A similarly named plugin from another publisher is not this project. Keep npm as the installation source unless an exact project-owned plugin artifact and its reviewed, installable version have been verified.
Manual (for development or customization)
# 1. Clone the repo
git clone https://github.com/FtlC-ian/openclaw-channel-zulip.git
cd openclaw-channel-zulip
# 2. Install dependencies
npm install
# 3. Build the compiled entry points
npm run build
# 4. Install as a local linked plugin
openclaw plugins install -l .
Configuration
Enable the plugin
Add the plugin id to plugins.allow in ~/.openclaw/openclaw.json:
{
"plugins": {
"enabled": true,
"allow": ["zulip"]
}
}
Minimal configuration
{
"channels": {
"zulip": {
"enabled": true,
"url": "https://your-org.zulipchat.com",
"email": "yourbot@your-org.zulipchat.com",
"apiKey": "your-zulip-api-key",
"streams": ["general", "support"],
"dmPolicy": "open",
"allowFrom": ["*"]
}
}
}
Full configuration reference
{
"channels": {
"zulip": {
"enabled": true,
// Zulip server connection
"url": "https://your-org.zulipchat.com",
"email": "yourbot@your-org.zulipchat.com",
"apiKey": "your-zulip-api-key",
// Or store the API key in an OpenClaw SecretRef:
// "apiKey": { "source": "env", "provider": "zulip", "id": "ZULIP_API_KEY" },
// Which streams to monitor. Omitted, [], or ["*"] = all public streams.
// For DM-only inbound handling, use groupPolicy: "disabled".
"streams": ["general", "bot-testing"],
// Optional inbound topic filters for monitored streams.
// Topic matching trims whitespace and is case-insensitive.
// Omit, use [], or include "*" to allow all topics.
"topics": ["support", "bot help"],
// Optional per-stream topic filters. Keys may be stream names or ids.
// A matching stream-specific filter further restricts the global topics list;
// streams with no entry use only the global topics filter.
"streamTopics": {
"general": ["support"],
"42": ["bot help"]
},
// Optional inbound-only policy overrides. ASCII decimal keys select
// stream IDs; every other key selects a normalized stream name.
"streamOverrides": {
"general": {
"enabled": true,
"requireMention": false,
"allowedTopics": ["support", "bot help"],
"excludedTopics": ["private"]
},
"42": { "enabled": false }
},
// Default topic for outbound messages with no explicit topic
"defaultTopic": "bot replies",
// Chat mode: "oncall" (mentioned only) | "onmessage" | "onchar"
"chatmode": "oncall",
// DM policy: "open" | "pairing" | "allowlist" | "disabled"
"dmPolicy": "open",
"allowFrom": ["*"],
// Group policy: "open" | "allowlist" | "disabled"
"groupPolicy": "open",
// Opt in to marking successfully handled inbound messages read.
// Default: false.
"markHandledRead": true,
// Truthful lifecycle reactions on the inbound message. The defaults show
// queued, thinking, tool category, compaction, stalls, done/error, and
// a separate robot while one or more spawned children are active.
"reactions": {
"enabled": true,
"clearOnFinish": true,
"subagent": "🤖",
"timing": {
"stallSoftMs": 10000,
"stallHardMs": 30000
},
"emojis": {
"thinking": "brain",
"coding": "computer",
"web": "🌐"
}
},
// Optional edit-in-place progress feedback (disabled by default).
// Text-only replies replace this message. Media and interactive replies
// remove it before sending normally; silent or cancelled turns remove it.
// Failed turns keep the placeholder and replace it with errorText.
"thinkingPlaceholder": {
"enabled": false,
"text": "Thinking…",
"errorText": "I couldn't complete that response."
},
// Model-controlled reaction prompt guidance: "off" | "minimal" | "extensive"
// This does not enable automatic status/progress reactions.
"agentReactionGuidance": "minimal",
// Block streaming (real-time reply chunks)
"blockStreaming": true,
"blockStreamingCoalesce": {
"minChars": 1500,
"idleMs": 1000
},
// Legacy compatibility field. Accepted but intentionally has no runtime effect.
// Message-tool actions are governed by actionTiers; provider-specific operations
// need a separately reviewed Zulip-owned agent tool.
"enableAdminActions": false,
// Per-account message-tool action tiers (defaults: messages "on",
// moderation "off", streamAdmin "off"). Gated-off actions are hidden from
// tool discovery and rejected before any Zulip request.
// moderation grants message edit and delete/unsend; streamAdmin grants
// stream create/edit/delete.
"actionTiers": {
"messages": "on",
"moderation": "off",
"streamAdmin": "off"
},
// Multi-account: uncomment to run multiple bots
// "accounts": {
// "primary": { "url": "...", "email": "...", "apiKey": "..." },
// "secondary": { "url": "...", "email": "...", "apiKey": "..." }
// }
}
}
}
For a running OpenClaw 2026.10.3 Gateway, apply the configured plugin from an operator terminal (not from an agent turn using Zulip):
openclaw plugins reload zulip --wait
If the Gateway is not running, the saved configuration applies on its next start. The reload guidance below targets OpenClaw 2026.10.3; on older supported hosts, consult that host's CLI help and plugin lifecycle documentation.
Stream scope and DM-only mode
Omitting streams, setting it to [], or including "*" monitors all public
streams. An empty list does not mean DM-only. Stream messages still have to pass
the group, sender, mention, and topic policies before the bot responds.
For DM-only inbound handling, set groupPolicy: "disabled". This drops all
stream messages regardless of streams or wildcard group settings. Keep
dmPolicy and allowFrom configured for the DMs you want to accept.
To accept stream messages only from selected senders in one stream, set these
fields under channels.zulip or the relevant account:
{
"streams": ["bot-testing"],
"groupPolicy": "allowlist",
"groupAllowFrom": ["sender@example.com"]
}
Replace bot-testing with the stream name and sender@example.com with an allowed
sender's Zulip email address (sender_email), not their numeric user ID. Mention
and topic policies still apply. A streamOverrides rule
with enabled: true can activate another stream, so remove such overrides if
you want a strict single-stream scope.
These settings filter inbound handling, not event-queue subscriptions. The plugin still registers for all public stream events to receive DMs alongside stream events. They do not disable outbound stream actions.
Doctor and allowlists
openclaw doctor previews unsafe DM/group policy, all-public-stream scope,
expanding overrides, and configured reaction approvals without explicit approvers.
openclaw doctor --fix only removes exact duplicate sender entries; it does not
choose access policies, approvers or credentials.
In an authorized Zulip chat, use /allowlist list all --config to inspect access,
/allowlist add dm owner@example.com --config for DM senders, and
/allowlist add group sender@example.com --config for group senders. Add
--channel zulip --account <id> to select an account.
/allowlist add group stream:bot-testing --config enables a stream override;
remove group stream:bot-testing --config explicitly disables that selector,
without turning an emptied legacy streams list into all-stream access.
Removing the last effective group sender disables group handling to prevent DM
fallback from reopening access. Removing * from open DMs selects allowlist
policy to keep config valid. Later additions do not automatically reopen disabled
group handling. See adapter contracts and live checks.
Handled-message read state
markHandledRead is disabled by default and may be enabled globally or for an
individual account. When enabled, the plugin marks an inbound DM, stream, or
topic message read only after reply dispatch has settled successfully and its
accepted durable receive record has completed. A successful silent turn is
handled; a cancelled or failed turn is not. Messages dropped by authorization
or routing policy, messages already read, duplicate deliveries, and messages
without durable-completion proof are not changed.
The read update is best-effort and safely batched per account. An API failure is logged without turning an otherwise successful reply into a failure or causing the message to be dispatched again.
Per-stream inbound policy and migration
Existing configurations do not need changes. streams, topics, and
streamTopics keep their previous behavior. streams remains the legacy
inbound allowlist, while streamTopics further restricts the account-level
topics allowlist.
streamOverrides adds field-by-field inbound overrides with this precedence:
- Account defaults (
requireMention/chatmodeandtopics). - Legacy
streamsactivation and the additionalstreamTopicsrestriction. - A matching normalized stream-name override.
- A matching decimal stream-ID override.
An omitted field inherits the lower-precedence value. An explicit
enabled: true may activate a stream outside streams; enabled: false
disables inbound handling. allowedTopics replaces lower-precedence allowed
topic filters when set. Empty allowedTopics, or one containing "*", allows
all topics. excludedTopics is applied afterward, so exclusions win;
["*"] excludes every topic.
Names are trimmed and matched case-insensitively against the authoritative
name on the Zulip message. Keys containing only ASCII digits are always IDs;
leading zeroes are canonicalized, so "017" selects ID 17. Values such as
"1e3", "0x11", and "+17" are names, never coerced IDs. Duplicate keys
after normalization are rejected. ID rules are the safest choice across
stream renames. A replayed message never uses its historical name when current
stream metadata is unavailable.
These rules govern inbound activation only. Outbound send, read, search, and react actions follow the messages tier below.
Message action tiers
The shared message tool is gated per account by channels.zulip.actionTiers:
| Tier | Grants | Default |
|---|---|---|
messages | send, read, search, react, channel-list | "on" |
moderation | message edit, delete, unsend | "off" |
streamAdmin | channel-create, channel-edit, channel-delete | "off" |
Gated-off actions are not advertised to agents and are rejected by the plugin
before any Zulip network request. Edit/delete/unsend require the moderation
tier even for the bot's own messages because the adapter does not resolve
message ownership over the network. See docs/ACTION_CAPABILITIES.md.
Lifecycle reactions
When channels.zulip.reactions.enabled is not false, the plugin uses OpenClaw's
public status-reaction controller. Reactions reflect actual queued, reasoning,
tool-category, compaction, stall, done, and error events; native Zulip typing
continues independently and no placeholder message is posted. clearOnFinish
defaults to true, so the terminal reaction is removed after its configured
hold. Set it to false to retain the final done/error reaction.
The dedicated subagent reaction is driven only by subagent_spawned and
subagent_ended hooks. It remains while at least one child run bound to that
exact inbound turn is active, including concurrent children and children that
outlive the requester's reply. The default is 🤖. Built-in lifecycle
Unicode values (👀, 🧠, 🛠️, 💻, 🌐, 🛫, 🏗️, 💁, ✅, ❌,
⏳, ⚠️, 🗜️, and 🤖) and named Zulip emoji are supported; arbitrary
Unicode is rejected because Zulip requires exact reaction metadata. An empty
string suppresses that state. The built-in defaults use these Unicode values
so the plugin always sends Zulip's required emoji code and reaction type.
Account-level
reactions.emojis and reactions.timing override global
messages.statusReactions values. The legacy onStart, onSuccess, and
onError keys remain accepted as queued, done, and error aliases, and an
explicit empty legacy value suppresses its state.
This subagent indicator covers children launched through OpenClaw's
sessions_spawn lifecycle, which emits the public hooks above. Codex-native
collaboration tasks do not currently expose that lifecycle through OpenClaw's
public plugin hooks, so they cannot truthfully drive this indicator.
How to get a Zulip API key
- Log in to your Zulip organization
- Go to Settings → Your bots (or create a bot at Settings → Bots → Add a new bot)
- Copy the bot's email and API key
- Use
https://your-org.zulipchat.comas theurl
Approvals and session binding
Topic-scoped conversations now resolve through the SDK session-conversation hook, plus the plugin ships a bootstrap-safe session-key-api.ts export for core fallbacks.
Basic approval authorization is now wired through approvalCapability, using normalized Zulip identities from allowFrom as the first pass.
Topic isolation and migration
Each topic uses a versioned, opaque session identity scoped to the agent,
configured account, normalized Zulip URL, bot email, and numeric stream ID.
Unicode 16 lowercase matching preserves case aliases while keeping punctuation,
spacing, and distinct Unicode sequences separate. Release A and Release-A
no longer share history. Topic isolation applies even with session.groupScope: "main".
Stream bindings still select the agent; parent sessions never seed topic history.
Wire topics remain readable and are stored separately from the session identity.
Destinations and receipts use explicit target topic, inherited threadId, account
defaultTopic, then general, in that order. Explicit empty topics stay empty;
inbound messages without a subject are rejected. replyToId is a message ID.
Empty topics require a Zulip server that supports them.
The first message after upgrade starts a fresh v2 context. A rename or move uses the destination's context, and changing account, endpoint, or bot identity also starts fresh. No legacy history is imported automatically. Use the included migration tool to preview and archive old topic sessions through the gateway API while all Zulip accounts remain stopped. Archives follow ordinary OpenClaw history and retention policy.
Directory and name resolution
The channel directory lists the bot identity, active DM peers, and subscribed
streams. User targets are user:email; stream targets are stream:ID.
Names and emails resolve case-insensitively with exact matching. Numeric selectors
match only IDs (leading zeroes are ignored); user email selectors match only real
email addresses, and other selectors match only names. Numeric stream names are
not name selectors. Duplicate matches within a field return an ambiguity error;
use an email/user ID or stream ID to disambiguate. Active bots with sendable emails
are included under the same DM policy as humans; inactive or addressless bots are
excluded. Bot status is not needed for this directory choice.
Explicit stream topics are preserved and checked against the account's topic policy.
Listings respect the selected account's DM policy and allowlist (including scoped pairing approvals), stream selection and overrides, and disabled group policy. Group sender allowlists govern inbound senders, not stream membership. Unsubscribed streams are not exposed or resolved, even if public. API failures remain failures; missing or disallowed targets return an unresolved result without hidden metadata.
API data is cached for 60 seconds per config/account/credential identity, with at most 32 resource entries per config. Every lookup sweeps expired identities before returning a cache hit; capacity evicts the oldest inserted resource. Policies are checked on every call. Live directory variants refresh the cache. Resolving a stream name to its numeric ID also permits the existing threading adapter to match the current numeric stream route; threading behavior itself is unchanged.
Delivery fallback
If OpenClaw loses a saved delivery route and supplies only an opaque Zulip session identity, the plugin forwards the message to the selected bot's active owner. It discovers the owner through the Zulip API; an email does not need to be hard-coded. The message includes a routing-failure note, the unresolved session target, and the selected account. Attachments remain attached, and receipts identify where each part actually went. The recovery delivery does not create or rebind a conversation session.
If the owner cannot be resolved, an explicitly configured diagnostics topic can receive the message instead:
{
"channels": {
"zulip": {
"routingDiagnosticsTarget": "stream:private-ops:openclaw-diagnostics"
}
}
}
Choose a private stream/topic authorized to receive the original content. The setting
also works under accounts.<id>. If neither owner nor diagnostics target is available,
delivery fails with a configuration error; it never guesses a stream. If a recovered
send itself fails, normal failure/retry behavior applies without redirecting it again.
Ordinary destination errors and API send failures do not trigger this fallback.
Some core session-key fallbacks omit the original account. In that case, OpenClaw's
selected/default account supplies the bot whose owner is notified. The note makes
clear that the original account and topic could not be recovered. This fallback is
also useful for the core sessions_send lookup limit of 200 sessions; it does not
remove that core limit.
Direct-message isolation and rotation
Zulip DMs always use an isolated OpenClaw session keyed by agent, channel,
normalized account id, Zulip realm, bot identity, and sender identity. This
remains enforced when the global session.dmScope is main; explicit identity
links do not merge Zulip DM sessions.
The isolated key format replaces older Zulip DM keys. After upgrading, each DM starts a fresh session on its first message. Existing transcripts remain on disk under their old keys but are not imported, because importing a previously shared session could copy another sender's context into the isolated session.
Idle rotation is owned by OpenClaw. Configure its supported direct-session policy:
{
"session": {
"resetByType": {
"direct": { "mode": "idle", "idleMinutes": 1440 }
}
}
}
The host uses the last real interaction and considers the exact expiry timestamp fresh; the session rotates on the next millisecond. Restarts preserve that timestamp. The supported host SDK does not publish a plugin-owned turn-count session-rotation API. The plugin therefore does not create parallel session state or approximate turn rotation. Turn-count rotation remains blocked on a public host policy/API.
Topic history context (opt-in)
Set channels.zulip.topicHistory (or accounts..topicHistory) to an object with mode "off" (default), "on-demand", or "always". Defaults: maxMessages 20, maxChars 6000, maxAgeHours 72, timeoutMs 2000. Allowed ranges are 1–100 messages, 256–20000 characters, 0.01–8760 hours, and 1–10000 milliseconds.
For example: topicHistory: { mode: "always", maxMessages: 20, maxChars: 6000, maxAgeHours: 72, timeoutMs: 2000 }. To enable only stream 18, leave the account default off and set streamOverrides: { "18": { topicHistory: { mode: "always" } } }. Stream overrides merge individual fields: account, stream name, then stream id. An account-level object follows normal account configuration precedence.
On-demand fetches when the text contains a whole-word reference to earlier, previous/previously, last time, discussed, remember, recap, history, already, or before; it also fetches when the SDK reports no prior session timestamp. If the prior-session lookup is unavailable/fails, only the text heuristic applies. This is not a semantic classifier and may have false positives/negatives. A session timestamp is an existence proxy, not a transcript inspection; compaction does not reset it. Ask explicitly about earlier discussion after compaction.
Only the exact current stream id and topic are queried with this account's Zulip credentials. Returned records are independently checked for matching scope. Selection is newest first, strictly before the triggering message, within the age window. At most 2 × maxMessages + 1 records are fetched (maximum 201), so widget-heavy topics can yield fewer than maxMessages. Missing/future timestamps are excluded. The full rendered block, including metadata/fences, is bounded by maxChars; content may be truncated. HTML is stripped, and sender names, ISO timestamps and content are encoded inside an explicitly untrusted JSON fence. Delimiter characters are escaped in all fields; quoted history is data, never authority. This mitigates but cannot eliminate model prompt-injection risk.
Bot progress cards, drafts, approval/question controls and companions tracked by the existing account-scoped durable binding store are excluded; widget submessages, standard progress/approval/placeholder text and configured thinking placeholder text provide fallback filtering. Ordinary bot replies remain useful history. Deleted/expired binding records cannot identify arbitrary old draft text. Fetch/store errors or timeouts drop the history and do not fail the inbound turn. The existing client has no request cancellation hook: timeout stops waiting, not the underlying request; late settlement cannot inject history into the turn. No session transcript is read or rewritten by the feature; context flows through the SDK inbound BodyForAgent field while raw/command input stays unchanged.
DM history is deliberately unsupported in this version, including one-on-one DMs, to avoid broadening the existing private-conversation permission surface.
Zulip channel plugin for OpenClaw — concurrent message processing, native session conversation binding, file uploads, approval hooks, and native message actions.
Reaction-triggered go (opt-in)
Set this at channels.zulip or under an account:
"reactionTriggers": {
"enabled": true,
"emoji": "fast_forward",
"message": "Go ahead."
}
Default is off. Emoji defaults to fast_forward (⏩); colon-wrapped names are accepted.
The injected message defaults to "Go ahead." and is limited to 500 characters.
Only active human users reacting to this bot's messages can trigger turns. Stream
topics retain their existing session; two-person DMs retain the reacting user's
direct session. Group DMs are deliberately excluded because they have no existing
group-DM session contract. The bot must be subscribed to the stream to receive its
reaction events.
Every trigger goes through normal inbound sender, DM/group, stream/topic, mention
and onchar policies. A reaction does not count as a mention: with the default
message, mention-required streams will not trigger. Use a stream override with
requireMention: false only where you already want unmentioned typed messages
accepted. Running turns use the same core admission/queue behavior as typed input.
Approval prompts (including companion widgets) are not continuation targets;
the trigger emoji must differ from both approval emojis, including inherited
account configuration. Reaction removals never trigger turns.
Hosts whose CLI validates only the packaged JSON schema cannot express cross-field
emoji equality: a collision may pass config set --dry-run, but the plugin rejects
it when resolving the effective account, before startup or live event handling.
An atomic plugin-state ledger records each (message, user, emoji) admission without TTL or eviction, so removing/re-adding, reconnecting and ordinary ingress retention expiry cannot trigger it again. It stores only hashes, not content or credentials. The ledger uses one row per realm/bot/account (up to ten scopes) and fails closed if state is unavailable, full, or cannot be written. At the host's per-value size limit, new triggers stop rather than evicting old identities. Do not clear this state unless deliberately resetting repeat protection.
Admission is recorded before enqueueing: a crash or write failure between those operations can lose a tap, but never makes the same tap eligible again. Already queued work retains normal durable ingress retry behavior. This is an at-most-once trigger admission, not an exactly-once guarantee for agent/tool execution.
Approval reactions
Exec and plugin approval messages retain their zform buttons and also offer
✅ Allow once and ❌ Deny reactions. Only explicit account allowFrom
email identities may use reactions; an empty list does not make reaction
approvals public. Bot reactions, removal events and unrelated messages are ignored.
For the claude-cli runtime (which blocks inside Bash), enable core forwarding:
{ "approvals": { "exec": { "enabled": true, "mode": "session" } } }
Merge this into existing config; keep explicit Zulip allowFrom approver emails.
No Zulip native-client config is required. Core forwarding and structured same-chat
prompts use the public outbound presentation renderer to retain zforms and bind
reactions; plain assistant /approve text is never inferred as an approval.
targets/both forwarding modes are also supported; no separate Zulip native
prompt delivery or forwarding suppression is installed.
Set channels.zulip.approvalReactions.approve / .deny (or the same keys under
accounts.<id>) to supported Unicode or named Zulip emoji. Empty values are invalid;
colliding emoji disable the binding rather than create an ambiguous approval.
There is deliberately no reaction for Allow always. Typed /approve remains
available, and zform buttons use the same canonical first-wins resolver.
After resolution, the prompt starts with a clear status such as
✅ Approved (allow once) by `Ian F`, ❌ Denied by `Ian F`, or ⌛ Expired.
Only the ID and already-displayed command remain; obsolete instructions are
removed along with the companion zform and bot-seeded reactions. Unknown remote
approvers are not invented (Resolved elsewhere: allow-once).
Core closures use human statuses such as “⚪ Cancelled: approval session ended
before a decision”, not internal resolver codes as approver names. Unrecognized
explicit system resolvers show ⚪ Closed by the system with an inert reason.
Reaction approvals are enabled on audited hosts 2026.9.3, 2026.9.6 and 2026.10.1-beta.1/beta.2 using only public SDK exports. Delivered targets expire after 24 hours; the Gateway still enforces actual approval expiry. Without a passive observer, external decisions are reflected on the next local interaction, not proactively.
The web client implements zform; desktop embeds the web client. Flutter currently supports poll submessages but explicitly leaves zform unsupported. See approval implementation and verification notes for source references, lifecycle details and limitations.
Session progress card (opt-in)
Set channels.zulip.progressCard.enabled: true (or under an account) to mirror
the agent's core progress_card calls into one editable message per conversation.
It is off by default and independent of the per-turn streaming.mode: "progress" draft.
Cards show markdown and ordered ✅ / ▶ / ◻ steps; clearing leaves a short inert state.
Set channels.zulip.progressCard.activeEmoji: "waiting" (or
channels.zulip.accounts.<accountId>.progressCard.activeEmoji) to replace ▶ with
:waiting: for the in-progress step. Use a simple emoji name without colons
(letters, digits, underscores or hyphens; for example dark-waiting). Custom GIF emoji animate
in Zulip messages; upload the emoji to your realm first. Completed and pending
steps are unchanged, and omitting activeEmoji keeps ▶.
Control UI edits do not show until the agent's next tool update. Exact mirroring
requires an upstream hook-bound read (GitLab #8). Unknown session routes are ignored.
CLI agents using the loopback MCP bridge also emit after_tool_call with the
session key; they do not require a separate transcript hook. Enabling this feature
does not replay the current card. A route must be bound by enabled inbound or
outbound traffic before a subsequent tool update can be mirrored. If a channel
reload is deferred while runs are active, saving the setting is not proof that
the running channel has applied it. See the MCP coverage audit.
Restart records use the public plugin state store and contain only identifiers, conversation topics, hashes and revision floors, never message content or credentials. They share a bounded 40-record budget across accounts (legacy hosts reserve 950 of the 1000 plugin-state rows for ingress); records expire after 30 days. Pending approval controls are rebound from the public pending lists. Native questions are probed with a denied authorizer on 2026.9.6 and later. On 2026.9.3, which lacks that safety capability, question widgets are terminalized instead of risking an answer replay. Resolved/unknown recovery states have generic inert labels because message content and terminal decision history are not stored. Interrupted per-turn drafts are deleted on startup; cards retain their message ids. Recovery terminal notices are at-most-once: a reserved send is never replayed after an interrupted or ambiguous POST, so a notice can be omitted. Source widget deletion and record cleanup remain retryable without duplicate notices.
Why concurrent processing?
Most channel plugin implementations process incoming messages one at a time — each message waits for the previous one to finish before starting. Under load (e.g. a burst of messages after reconnect) this creates noticeable latency for later messages.
This plugin processes events concurrently: each message is dispatched immediately (fire-and-forget with error handling) and a small 200 ms stagger is introduced between starts for natural pacing. The result is that ten simultaneous messages all start processing within ~2 seconds of each other instead of serially.
Troubleshooting: stream replies
If the bot appears to receive DMs but does not reply in Zulip streams, check the stream policy first. Stream messages are treated as group messages, so groupPolicy controls whether the bot is allowed to respond there.
Common setups that intentionally produce no visible stream reply:
groupPolicy: "disabled"drops all stream messages.groupPolicy: "allowlist"requires the sender to matchgroupAllowFrom; if the sender is not allowed, the message is ignored.chatmode: "oncall"orrequireMention: truemeans the bot only replies when it is mentioned.topicsorstreamTopicsfilters only allow matching topics; messages in other topics are ignored.- A matching
streamOverridesrule can disable the stream, change its mention requirement, or allow/exclude topics.
For the simplest “reply in monitored streams” setup, use groupPolicy: "open" with the stream listed in streams, then add mention or topic filters only after the basic path works.
Updating
Tracked npm installation
Update the existing install record by runtime plugin id:
openclaw plugins update zulip
On OpenClaw 2026.10.3, a running Gateway applies saved plugin changes through runtime replacement. A saved installation/update is not proof that replacement was applied. Check the operation's final applied receipt or reported failure.
Saved changes, but replacement not applied / 60-second drain timeout
If the operation saved the package/configuration but runtime replacement did not apply because retained work exceeded the default 60-second drain budget, let the current Zulip work finish and use a quiet operator terminal:
openclaw plugins reload zulip --wait
Do not run this from a Zulip agent turn that itself holds the plugin: it can wait
for its own turn and make no progress. Avoid submitting new Zulip work during
maintenance. --wait removes the admitted-work drain deadline, not the deadlines
for service shutdown, cleanup, or recovery. New runs wait behind the replacement
gate; Ctrl+C or a disconnected request cancels the wait. Wait for the final
applied receipt before calling the update active.
Do not repeat installation, delete plugin files, or restart the Gateway to
recover this drain timeout. Reload uses the already saved changes. Other
activation/cleanup failures need inspection of the reported error rather than
blind retries. See the OpenClaw reload documentation
and openclaw plugins reload --help for the host's current behavior.
Linked local source
For a development installation linked with plugins install -l ., rebuild before
reloading; reload does not compile source. On OpenClaw 2026.10.3, run from the same
quiet operator terminal:
cd openclaw-channel-zulip
git pull
npm install
npm run build
openclaw plugins reload zulip --wait
Continuous integration
Pull requests and pushes to main run the release-blocking CI workflow on Node 24.16 and Node 26.x, matching the supported runtime ranges. Each run installs from pnpm-lock.yaml with --frozen-lockfile, builds, runs the full test suite, checks whitespace errors with git diff --check, packs the release artifact, installs it with the locked OpenClaw host in a clean temporary project, and imports its public package entry point. The workflow has read-only repository permissions and does not receive repository or Zulip secrets.
The scheduled OpenClaw compatibility (advisory) workflow is intentionally separate from release gating. Once a week it chooses the first eligible release from OpenClaw's latest and extended-stable npm channels that has been published for at least 24 hours, then installs and tests it in a temporary copy of the plugin. That temporary install and the packed-artifact smoke test enforce pnpm's 24-hour release-age rule and may update only disposable lockfiles; they never change or commit this repository's lockfile. A failure identifies compatibility work to investigate; it does not replace the locked CI evidence required for a release.
Plugin ID
The plugin id is zulip (defined in openclaw.plugin.json). Use this id in plugins.allow and with openclaw plugins commands.
Resources
Related
- zulcrawl — Zulip archive & search CLI. Mirrors streams, topics, and messages into local SQLite with FTS5 full-text search. Pairs with this plugin to give AI agents searchable access to Zulip conversation history. Inspired by steipete/discrawl.
License
MIT © FtlC-ian
