⚡ Quick start
Get a bot answering DMs in four steps (channel @mention replies add one OAuth step — see Setup guide below):
- Create a Cliq bot — Zoho Cliq → Bots → Create Bot. Note the Bot Unique Name (
botId, not the internalb-...bot ID) and display name. - Get OAuth credentials — Zoho API Console (use your data center's domain) → Self Client → note Client ID + Client Secret.
- Install & configure
openclaw plugins install clawhub:@sprintcx/openclaw-cliq openclaw setup # pick "Zoho Cliq" — the guided flow writes, validates, and reports the account config - Wire the webhook — paste the Deluge handler into the bot's Message/Mention handlers, pointing at
https://<your-gateway>/cliq/webhook.
To install from a local checkout instead of ClawHub (the path used while developing and rolling this plugin out), see Install from a local checkout.
DM the bot → it answers. To also reply to channel @mentions and stream live edits, add the one-time refresh token in §3c.
Verified live. DMs and channel @mentions both round-trip end to end on a real OpenClaw gateway.
Features
- 💬 Messaging — DMs + channel @mentions via a Deluge webhook, outbound as the bot (DMs via
userids, channel posts viachannelsbyname). Inbound image / file / voice attachments are downloaded and handed to the agent.stop//stop/escinterrupts a running turn. Cliq Form submissions (structured input via the bot's Form Handler) are recognized and routed to the agent with their field values surfaced asFormValues/FormName; the agent can also solicit structured input by rendering a form as a nativepromptcard — a button click re-enters as a structuredFormValuesentry (parameter capture). - ✍️ Rich replies — Markdown → Cliq formatting, block-streaming live-edit of one message (not token-level growth — see
streamingbelow), interactive buttons & cards, slash-style commands, reply threading. Opt-in v3 Message Cards (apiVersion: { channelCard: "v3" }— DM cards already default to v3 viadmPost) addmodern-inline/prompt/pollthemes, supporting-contentslides(table / list / label / image / text blocks),modern-inlinesections(in-card labeled field groups), and athumbnailheader image. - ⚡ Message actions — Edit / delete / react to sent messages from the agent.
- 🔐 OAuth 2.0 —
client_credentialsfor DMs; a user-context refresh token for channel posts / message edits. Works on any Zoho data center. - 🛡️ DM security —
allowlist/pairing/open/disabledpolicies with an approval flow. - 🧩 Per-channel policy — Group admission + per-channel
requireMention, tool policy, and per-sender tool overrides. - 🔁 Reliability — Durable-before-ack ingest, de-dup on redelivery, bot-loop / self-message protection, outbound retry with error classification (parses the v3
{"message":"…"}error envelope). The generated Deluge handler mints a per-executioneventId, so a real Cliqmessage.idor thateventIdretains 30-minute replay protection while two deliberate identical commands (/newtwice) stay independent turns. Legacy handler scripts that forward noeventIdfall back to a content-derived id that expires after 60 seconds. Opt-in bounded inbound catch-up can recover a direct-chat message that exists in Cliq but never became a webhook turn, using the existingMessages.READendpoint on the next admitted DM — no polling loop or Cliq MCP tools. - 🔒 Hardened webhook — Constant-time secret compare, single-header auth, failed-auth rate limiting.
- 🩺 Operations —
openclaw status/channelshealth probe,openclaw directorylookup, stagedopenclaw cliq doctor, plugin doctor, interactive setup wizard, SecretRef credentials, security audit, session binding, multi-account, lifecycle hooks. Status reports the integration as a webhook/event-driven channel (mode: webhook, path/cliq/webhook); a configured account staysrunningfor as long as the gateway is up, andlastInboundAt/lastOutboundAtfill in after real traffic.openclaw security auditincludes Cliq-specific findings in its default sweep;--deepis only needed for live gateway probes. - 🧭 Bundled companion skill — installation also publishes the
cliqoperations skill to~/.openclaw/plugin-skills/cliq/, so agents can follow the supported setup, diagnostic, and defect-reporting workflow without a separate skill install. Its issue helper searches for duplicates and scrubs tokens, secrets, credentials, and email addresses before filing onsprintberlin/openclaw-cliq.
Known limitation: the bot can send reactions, but inbound reaction notifications (being told when a user reacts) are not yet possible — the OpenClaw plugin SDK exposes no inbound non-message event hook for external channel plugins. Tracked upstream: openclaw/openclaw#100447.
Setup guide
Everything that must be configured on the Zoho side so the cliq channel plugin can talk to your OpenClaw gateway.
📍 Pick your Zoho data center first. Zoho stores each account in one region, and the domain differs per region. The URLs below use
.com(US) — replace.comwith your data center's domain (.eu,.in,.com.au,.jp,zohocloud.ca,.sa,.com.cn). The plugin's OAuth + API calls default to the EU endpoints; if your account is not on EU, also setoauthBaseandapiBasein the config (see §4). Not sure which region you're on? Check the domain you log into Zoho at — e.g.cliq.zoho.eu→ EU. Full mapping: Data centers.
Prerequisites
- OpenClaw
2026.8.2minimum. The plugin is typechecked and built against2026.8.2, then the same artifact is smoke-tested on the exact versions in.github/openclaw-compat.json. CI also checks runtime SDK imports against each published version's export table. A newer release is not considered verified until it is added to that matrix. - A Zoho account with access to Zoho Cliq and the Zoho API Console.
- A running OpenClaw gateway reachable from the public internet (so Zoho can call the webhook). A reverse proxy, Cloudflare Tunnel, or
ngrokall work — see Expose the webhook publicly. - The bot owner must be able to create a bot in Cliq (admin / developer permission).
1. Create a Zoho Cliq Bot
Open the bot builder: click your profile picture (top-right in Zoho Cliq) → under My Cliq choose Bots & Tools.
<p align="center"> <img src="https://raw.githubusercontent.com/sprintberlin/openclaw-cliq/main/assets/cliq-bots-tools-menu.png" alt="Zoho Cliq — profile picture menu → My Cliq → Bots & Tools" width="440"> </p>-
In Bots & Tools, open the Bots section.
-
Click Create Bot.
-
Fill in:
- Bot Name (display name, e.g.
OpenClaw Agent, max 20 characters) — this is what users see. - Bot Type: choose Custom Bot (a Deluge-backed bot whose handlers forward to the webhook). A pure "Webhook Bot" is not required — we use a Custom Bot with a Deluge handler that
invokeUrls our endpoint.
Cliq assigns the unique name itself from the display name; it is not a field you fill in. Read it back from the created bot — that assigned value is the
botIdyou put in the plugin config.Creating the same bot over the API (or the Zoho MCP server's
ZohoCliq_create_bot) uses different field names than this form:name,description,scope(organization|team|personal, required),execution_type(deluge|webhook), andchannel_participation. There is nobot_unique_nameorbot_typefield — both are rejected asextra_key_found, andunique_namecomes back in the response. A verified direct REST create withscope: "organization"can still read back aspersonal; the API does not provide a proven scope-update correction. Open the bot in Bots & Tools and change its visibility there. The Zoho MCP create wrapper can also reject this valid body withinput_pattern_mismatch, so use the direct REST contract or the Cliq UI rather than treating MCP as reliable provisioning. - Bot Name (display name, e.g.
-
Set the bot's Functional Handlers:
- Mention Handler — fired when the bot is @mentioned in a channel.
- Message Handler — fired when a user DMs the bot directly.
-
Publish / Activate the bot (it must be active to receive events).
-
Users in the organization find the bot in Bots & Tools (or the bot directory) and subscribe to it. The first DM they send is what starts the conversation — until they subscribe and open the bot, they will not see a first-contact message.
openclaw setupcan inspect the documented bot state and, with explicit confirmation, send one labeled first-contact DM to a directory-resolved user. Subscription membership is only shown when Zoho exposes it to the calling identity; otherwise it is reported asunknownrather than guessed. -
Invite the bot into the channel(s) where it should respond to mentions. In a Cliq channel: ⋯ → Bots → add your bot. The bot can always receive DMs without an explicit invite. After it is added, the default group policy still requires an
@mentionunlessgroups.<channel>.requireMentionis set tofalse.groupPolicy: "allowlist"admits only listed channels;disabledblocks all group traffic; trusted-organization mode is a deliberate, acknowledged broadening of that admission, not a signed tenant check.
The Bot Unique Name you pick here is the
botIdconfig field. The display name isbotName(used for @mention stripping in the agent-visible text). Do not substitute Zoho's internalb-...bot ID: that separate ID is required by bot/handler provisioning CRUD, while runtime message paths andbotIduse the unique name. See the verified provisioning API contract.
2. Configure the Webhook
The plugin registers a single HTTP route at POST /cliq/webhook on your OpenClaw gateway. Zoho Cliq's Deluge bot handler must POST every mention / message event to that URL.
-
Pick a strong random secret (e.g.
openssl rand -hex 32) — this becomes yourwebhookSecret. -
Note the public URL of your OpenClaw gateway, e.g.
https://openclaw.example.com. The full webhook URL is:https://<gateway-host>/cliq/webhookThe route is registered with
auth: "plugin", so no additional gateway-level bearer token is required; thewebhookSecretis verified by the plugin itself via thex-cliq-webhook-secretheader. -
Make sure the gateway host is reachable from the public internet (Zoho's servers POST to it). See Expose the webhook publicly for deployment options (VPS + reverse proxy, domain-free sslip.io/nip.io + Caddy, Cloudflare Tunnel, existing proxy, dev tunnels, self-hosted tunnel) with secure Caddy and nginx examples.
Verify the public path before wiring up Zoho — this checks DNS, TLS, the reverse proxy, the route, and the shared secret, and finishes with a probe that reaches the plugin without dispatching an agent turn:
openclaw cliq webhook-preflight https://<gateway-host>/cliq/webhookWhen the checked URL matches the configured
channels.cliq.publicWebhookUrl, a passing run recordschannels.cliq.inboundVerifiedAtand a proven failing run recordsinboundVerificationFailedAt(clearing any stale verification) — so verifying via the CLI counts just like verifying via the wizard. The method/reachability boundary retries transient502,503,504, connection-refused/reset, and timeout outcomes three times with 250 ms then 500 ms backoff. Exhausted transient startup conditions remain inconclusive: human and JSON evidence show the bounded attempt count and delay, the command exits non-zero, and both existing verification timestamps are preserved. The write never happens for a URL that is not this install's, and--no-writekeeps the command a pure read-only probe:openclaw cliq webhook-preflight https://<gateway-host>/cliq/webhook --no-writeopenclaw setupruns the same check and will not report inbound Cliq as ready when it fails. When the configured account hasbotId, client credentials, and theZohoCliq.Bots.READscope, the preflight resolves the configured bot unique name through the complete paginated bot listing to Zoho's internalb-…id (or uses an already-internal id without listing), then reads the bot's Message and Mention handler scripts and compares SHA-256 fingerprints of theirwebhookSecretliterals with the resolved config secret, plus eachwebhookUrlwith the URL being tested. The resolution is cached for the diagnostic run. A missing or ambiguous unique-name match, incomplete listing, API failure, missingBots.READ, nobotId, unreadable bot, or hand-written script whose assignments cannot be recognised makes that stage skipped, never passed; raw API responses, handler bodies, OAuth tokens, and secrets are never included in CLI or JSON output. A mismatch is a failing stage that names the handler without printing either secret. The output then says explicitly that the green network/authentication stages do not prove Zoho holds the same secret. Every preflight request identifies itself asopenclaw-cliq-preflight/<package-version> (+https://github.com/sprintberlin/openclaw-cliq)so edge/WAF rules can allowlist it deliberately (the version is this package'spackage.jsonversion); use--user-agent <value>when you need to reproduce the identity your Zoho/Deluge delivery uses. An HTTP403is reported as a probable edge/WAF/bot-rule block with that remediation, not as proof that the route or reverse proxy is broken. For a full staged diagnostic of config, OAuth, capabilities, and inbound, seeopenclaw cliq doctor.To check only whether the gateway registered the route (no public path, no secret, no agent turn):
openclaw cliq webhook-route # add --port <port> if the gateway is not on 18789405plus the plugin's own route signature means the route is registered. Anything else — including a bare404, which a proxy can generate on its own — is reported as inconclusive rather than as a missing route, and the command exits non-zero so it is safe as a deploy gate. Do not useopenclaw plugins inspect cliq --runtime --jsonfor this — it reports"httpRoutes": 0even when the route is live and serving traffic, because that command loads the plugin without activating it, so the registration step that adds the route never runs for it (upstream: openclaw/openclaw#130773). -
In the Cliq Bot's Deluge editor (see step 5 below), set the webhook URL and the secret header on every
invokeUrlcall.
3. OAuth / API Credentials
The plugin uses two OAuth grant types, because the client_credentials grant CANNOT obtain a usable token for the ZohoCliq.Channels.UPDATE or ZohoCliq.Messages.UPDATE scopes — Zoho issues a token whose response reports the scope, but the API rejects it on use with {"code":"oauthtoken_scope_invalid"}. So:
- Bot DMs (
/bots/{botId}/message, scopeZohoCliq.Webhooks.CREATE) →client_credentials(the plugin fetches a fresh access token automatically when the cached one expires; no refresh token, no user interaction). - Channel posts (
/channelsbyname/{unique_name}/message, scopeZohoCliq.Channels.UPDATE) and message edits (/chats/{chatId}/messages/{messageId}, scopeZohoCliq.Messages.UPDATE) → a user-context refresh token obtained once via the self-clientauthorization_codeflow. Native v3 typing (/api/v3/chats/{chat_id}/activities, scopeZohoCliq.Chats.UPDATE) is consented on the same Self Client refresh-token string. The plugin mints short-lived access tokens from the refresh token viagrant_type=refresh_tokenand caches them until they expire (~1h). Without a refresh token, channel replies and block-streaming live-edit will fail withoauthtoken_scope_invalid— DM-only setups keep working.client_credentialscan mint a token that reportsChats.UPDATE, and a live typing call has returned 204 with that grant, but operators must still add it to the Self Client string and regenerate the user-context refresh token used by the runtime profile.
3a. Create the OAuth client
-
Open the Zoho API Console — use your data center's domain (Data centers). Choose Self Client if you do not already have one for Cliq.
-
Create a Server-based Application (or Self Client) and note:
- Client ID
- Client Secret
-
Your data center's OAuth token endpoint (example uses US
.com):https://accounts.zoho.com/oauth/v2/tokenThe plugin defaults to the EU endpoint (
https://accounts.zoho.eu). If your account is on another data center, setoauthBase(andapiBase) in the config (§4) to match — see Data centers. -
Copy Client ID and Client Secret — they go into
clientId/clientSecretin the plugin config below.
3b. Consent the scopes
Consent once with everything you might need. Zoho scopes are not added retroactively to an existing consent. If you start with the runtime-only string and later want bot inspection or handler provisioning from
openclaw setup, you must re-consent the Self Client with the larger scope string and regenerate the refresh token — a second Generate Code round trip with a fresh 10-minute code. Pasting the combined profile below on the first attempt avoids that entirely; consenting a scope you never use costs nothing at runtime.
For a new installation, paste the combined profile from Capability profiles into the Self Client's Generate Code scope field. The individual scopes and what each one unlocks are listed here; both the client_credentials (DM) and refresh-token (channel/edit/delete/card/media/typing) paths need to be covered:
Each scope's grant is shown in parentheses — client_credentials is fetched automatically; refresh token requires the one-time §3c token.
ZohoCliq.Webhooks.CREATE(client_credentials) — Post bot DMs (the/bots/{botId}/messagesend path).ZohoCliq.Channels.UPDATE(refresh token) — Post bot messages to channels (the/channelsbyname/{unique_name}/messagesend path).ZohoCliq.Channels.CREATE(refresh token) — Post a v3 Message Card to a channel (only when thechannelCardfamily resolves to v3 — the v2 channel card path reusesChannels.UPDATE; opt-in, see §4).ZohoCliq.Channels.READ(client_credentials) — Read channel / chat metadata.ZohoCliq.Users.READ(client_credentials) — Resolve sender user info.ZohoCliq.Messages.UPDATE(refresh token) — Edit a sent message in place (block-streaming live-edit of one Cliq response).ZohoCliq.Chats.UPDATE(refresh token) — Native v3 typing viaPOST /api/v3/chats/{chat_id}/activitieswith body{"action":"typing"}(success is empty HTTP 204). A user id is not a chat id. This scope does not unlockGET /api/v2/chats. Cliq renders the indicator as the human refresh-token owner, never as the bot; the API exposes no sender override. The plugin therefore defaultsheartbeat.typingto"dm"and suppresses this activity in shared rooms.ZohoCliq.Messages.READ(refresh token) — Read recent chat messages to resolve an inbound file attachment's file id when a legacy/custom handler forwarded only its name (the plugin fetches the file message viaGET /api/v2/chats/{chatId}/messagesto recover the downloadable id). The current generated Message Handler forwards the Deluge FILE bytes directly as multipart, but this scope remains useful as a compatibility fallback and for quote/reply parent-text fetches.ZohoCliq.Messages.DELETE(refresh token) — Delete a sent message via the v3 bulk-delete endpoint (only when thedeletefamily resolves to v3 — the v2 single-message delete reusesMessages.UPDATE; opt-in, see §4).ZohoCliq.messageactions.CREATE(refresh token) — Add / remove message reactions (themessage(action=react)tool).ZohoCliq.Attachments.READ(refresh token) — Download inbound file / image / voice attachments (GET /api/v2/files/{id}) so they reach the agent.
Image analysis requires a vision-capable model. Plugin channels (like Cliq) route inbound images through the runtime's
media-understandingdescribe pipeline, which needs a vision-capable model to produce a text description. If your primary model is text-only (e.g.mimo-v2.5-pro), configure either (a) a vision-capable model as your primary, (b) an explicittools.media.imageprovider, or (c) a vision-capable fallback model the runtime can auto-discover. Without this, the agent receives the image file path but cannot analyze its contents. The turn degrades gracefully (no orphaned placeholder) — but the image content is not described to the agent.
If you previously consented with only the original three scopes, you must re-consent (generate a fresh self-client token) with
ZohoCliq.Channels.UPDATEandZohoCliq.Messages.UPDATEadded — channel replies will be rejected withinvalid_scope/ 401 until you do. Tokens minted beforeZohoCliq.Chats.UPDATEwas required fail native v3 typing withoauthtoken_scope_invaliduntil you re-consent the Self Client including that scope and regenerate the refresh token.ZohoCliq.Messages.DELETEandZohoCliq.Channels.CREATEare only needed when you opt the corresponding family into v3 (delete/channelCard— see §4); the v2 paths reuseMessages.UPDATE/Channels.UPDATErespectively, so if you keep those families on the"v2"default you can skip them. The v3 bot-DM endpoint (thedmPostdefault) uses the sameZohoCliq.Webhooks.CREATEscope as v2 DMs (client_credentials, no extra scope) — though some orgs may additionally requireZohoCliq.BotMessages.CREATE; if yours does, fall back withapiVersion: { dmPost: "v2" }. Reactions (ZohoCliq.messageactions.CREATE) are optional — skip the scope if you don't need thereactaction, and the plugin will simply not advertise reaction support.ZohoCliq.Messages.READis only needed for inbound image / file attachments (resolving a bot-handler file name to a downloadable id) and the quote/reply parent-text fetch — skip it for a text-only bot and those features degrade gracefully. LikewiseZohoCliq.Attachments.READis only needed for inbound media (downloading the resolved images / files / voice a user sends) — skip it for a text-only bot and the plugin degrades to "no media" for those messages.
Capability profiles
The plugin defines two capability profiles — each a named set of OAuth scopes
with a specific grant type. The capability matrix in src/capabilities.ts is the
single source of truth; openclaw doctor validates capabilities at setup time.
Combined profile (recommended) — runtime + setup in a single consent (14
scopes). This is the string to use for a new installation: one paste, one code
exchange, and both normal messaging and bot/handler provisioning from
openclaw setup work without a second consent. Copy it into the Zoho API
Console's Generate Code scope field:
ZohoCliq.Webhooks.CREATE,ZohoCliq.Channels.UPDATE,ZohoCliq.Messages.UPDATE,ZohoCliq.messageactions.CREATE,ZohoCliq.Attachments.READ,ZohoCliq.Messages.READ,ZohoCliq.Messages.DELETE,ZohoCliq.Channels.CREATE,ZohoCliq.Users.READ,ZohoCliq.Channels.READ,ZohoCliq.Chats.UPDATE,ZohoCliq.Bots.READ,ZohoCliq.Bots.CREATE,ZohoCliq.Bots.UPDATE
The two tables below break that string into its runtime and setup halves.
Runtime-only profile (minimal alternative) — the 11 scopes required for
normal DM/channel messaging and optional messaging features, with no bot
inspection or provisioning. Use this only if you are certain you will never
provision bots or handlers from openclaw setup; adding those scopes later
means re-consenting and regenerating the refresh token:
ZohoCliq.Webhooks.CREATE,ZohoCliq.Channels.UPDATE,ZohoCliq.Messages.UPDATE,ZohoCliq.messageactions.CREATE,ZohoCliq.Attachments.READ,ZohoCliq.Messages.READ,ZohoCliq.Messages.DELETE,ZohoCliq.Channels.CREATE,ZohoCliq.Users.READ,ZohoCliq.Channels.READ,ZohoCliq.Chats.UPDATE
| Capability | Scope | Grant | Required |
|---|---|---|---|
| DM send | ZohoCliq.Webhooks.CREATE | client_credentials | yes |
| Channel send | ZohoCliq.Channels.UPDATE | refresh_token | yes |
| Message edit / streaming | ZohoCliq.Messages.UPDATE | refresh_token | yes |
| Native chat typing (v3 activities) | ZohoCliq.Chats.UPDATE | refresh_token | yes |
| User lookup | ZohoCliq.Users.READ | client_credentials | yes |
| Channel lookup | ZohoCliq.Channels.READ | client_credentials | yes |
| Channel card (v3) | ZohoCliq.Channels.CREATE | refresh_token | no |
| Message read (file-id, quote) | ZohoCliq.Messages.READ | refresh_token | no |
| Message delete (v3) | ZohoCliq.Messages.DELETE | refresh_token | no |
| Reactions | ZohoCliq.messageactions.CREATE | refresh_token | no |
| Media download | ZohoCliq.Attachments.READ | refresh_token | no |
Setup / maintenance profile — scopes required for bot inspection and handler
provisioning from openclaw setup. These are not needed for normal messaging.
Inspect-only consent (ZohoCliq.Bots.READ) can look at existing bots but cannot
create or change anything; provisioning consent additionally includes the create and
update scopes:
ZohoCliq.Bots.READ,ZohoCliq.Bots.CREATE,ZohoCliq.Bots.UPDATE
| Capability | Scope | Grant | Consent tier |
|---|---|---|---|
| Bot read (inspect existing bots) | ZohoCliq.Bots.READ | client_credentials | inspect-only |
| Bot create | ZohoCliq.Bots.CREATE | client_credentials | provisioning |
| Bot / handler update | ZohoCliq.Bots.UPDATE | client_credentials | provisioning |
Inspect vs. create is a real difference. A token consented with only
Bots.READ+Bots.UPDATEis issued happily (the token response even reports both scopes) butPOST /api/v3/botsfails with{"code":"oauthtoken_scope_invalid"}— the error suggests a bad token when the real cause is the missingZohoCliq.Bots.CREATEconsent. Bot creation is destructive to probe, so this capability is reported from the granted scope set, never verified by actually creating a bot.
The bot/handler endpoint, method, and body details verified live — including the
internal b-... bot ID requirement — are recorded in the
verified provisioning API contract.
3c. Obtain the user-context refresh token (required for channel posts + edits)
Channel posts need a user-context token, which client_credentials cannot provide
(Zoho issues a client_credentials token that claims the ZohoCliq.Channels.UPDATE
scope, but the channel API rejects it with oauthtoken_scope_invalid). You get a
user-context token from a refresh token, obtained once via the Self Client's
authorization-code flow. This is a two-step process — a short-lived code that you
exchange for a permanent refresh token.
The default already skips this for bot DM posts. The
dmPostfamily defaults to v3 (see §4): bot DM posts route throughPOST /api/v3/bots/{botId}/messageswith theZohoCliq.Webhooks.CREATEscope obtainable viaclient_credentials, so a refresh token is not required for DMs. The v3 DM endpoint posts as the bot (sender identity preserved — the bot unique name is in the URL path) and sends the recipient key asuserids+sync_message: true, so the response carries the sentmessage_id+chat_id(undermessage_details.<userId>) — what thethinkingplaceholder and DM live-edit-in-place need to edit the reply in place. Some orgs may additionally requireZohoCliq.BotMessages.CREATEfor the v3 bot endpoint; if yours rejects the v3 DM post, restore the v2 path withapiVersion: { dmPost: "v2" }.Channel text posts still need the refresh token by default (
channelPostdefaults to v2 →Channels.UPDATE). OptingapiVersion: { channelPost: "v3" }routes channel text posts throughPOST /api/v3/channelsbyname/{name}/messages(alsoWebhooks.CREATE, so no refresh token) — but v3 channel posts return no message id, so live-edit for channel posts degrades to block-streaming (no win over v2). The message delete family stays v2 by default (Messages.UPDATE); opt{ delete: "v3" }for the v3 bulk-delete endpointDELETE /api/v3/chats/{chatId}/messagess?message_ids=<id>— that scope (Messages.DELETE) still needs a user-context refresh token, so the refresh token is still required for deletes even in v3. Channel card/button posts (in channels), media posts, and message edits still require the refresh token (channel card posts route through the v3 Message Card endpointPOST /api/v3/channels/{name}/messagewith theZohoCliq.Channels.CREATEscope — a user-context scope, same constraint asChannels.UPDATE; media posts stay on v2 indefinitely — v3 has no byte-upload surface, only a public-HTTPS-image Message-Card slide that posts as the user, not the bot; message edits stay on v2 indefinitely — v3 Messages has no single-message edit endpoint). DM card/button posts in v3 route through the v3 "Send a bot message" endpointPOST /api/v3/bots/{botId}/messageswith a top-levelcardfield — the sameWebhooks.CREATEscope as DM text posts (client_credentials, no refresh token needed), posting as the bot and addressing the recipient viauserids(no chat-id resolution needed). If you only need DM text + DM cards, the default already skips this step entirely. SkipMessages.DELETEfrom the scope if you don't use deletes; likewise skipChannels.CREATEif you don't use v3 channel cards. Verify your Zoho org accepts the v3 endpoints before relying on it.
Why only once: only the code is short-lived (10 minutes, single-use). The refresh token you get from it does not expire — it survives gateway restarts and any amount of downtime. You do this once per Cliq org and never again (unless you revoke it).
Step 1 — generate the code (valid 10 minutes):
- In the Zoho API Console (your data center) → your Self Client → tab Generate Code.
- Scope: paste the combined 14-scope profile below (comma-separated, no spaces). Zoho scopes are not added retroactively: choosing the runtime-only alternative now and adding setup/provisioning later requires a new consent and a regenerated refresh token.
ZohoCliq.Webhooks.CREATE,ZohoCliq.Channels.UPDATE,ZohoCliq.Messages.UPDATE,ZohoCliq.messageactions.CREATE,ZohoCliq.Attachments.READ,ZohoCliq.Messages.READ,ZohoCliq.Messages.DELETE,ZohoCliq.Channels.CREATE,ZohoCliq.Users.READ,ZohoCliq.Channels.READ,ZohoCliq.Chats.UPDATE,ZohoCliq.Bots.READ,ZohoCliq.Bots.CREATE,ZohoCliq.Bots.UPDATE - Time Duration: 10 minutes. Scope Description: anything (e.g.
openclaw). Pick your portal/org if prompted. - Click Create and copy the code — it looks like
1000.<hex>.<hex>.
Step 2 — exchange the code for a refresh token (do this within the 10 minutes):
The Self Client console gives you a code, not the token. Exchange it against your data
center's token endpoint (the example uses US .com — see Data centers;
no redirect_uri is needed for the self-client flow):
curl -X POST "https://accounts.zoho.com/oauth/v2/token" \
-d "grant_type=authorization_code" \
-d "client_id=<clientId>" \
-d "client_secret=<clientSecret>" \
-d "code=<code from step 1>"
The response is JSON:
{
"access_token": "1000....", // short-lived (~1h) — ignore it
"refresh_token": "1000....", // PERMANENT — this is the one you keep
"scope": "ZohoCliq.Webhooks.CREATE ZohoCliq.Channels.UPDATE ...",
"expires_in": 3600
}
Copy the refresh_token value.
Step 3 — store it: put the refresh_token in the plugin config as refreshToken (see §4).
From then on the plugin mints its own short-lived access tokens from it automatically
(grant_type=refresh_token), forever.
Troubleshooting:
invalid_codeon exchange → the code expired (>10 min) or was already used once. Generate a fresh one.- Native typing still
oauthtoken_scope_invalid→ therefreshTokenwas minted withoutZohoCliq.Chats.UPDATE. Re-consent the Self Client with the full scope list and regenerate the refresh token.- Channel replies still
oauthtoken_scope_invalid→ therefreshTokenis missing from config, or was minted withoutZohoCliq.Channels.UPDATE. Re-run 3c with the full scope list.channel_not_existson a real channel → the bot is not a participant of that channel (invite it: channel ⋯ → Bots), or you used the channel's display name instead of its unique name (the technical name — e.g. a channel shown as "Finance" may have the unique nameinvest).
If you skip 3c entirely, the plugin still works for bot DMs (the client_credentials
path); channel @mention replies, live-edit message edits, and native v3 typing require the
user-context refresh token.
4. OpenClaw Configuration
Add the cliq channel to your openclaw.json (or via openclaw setup / the setup wizard's applyAccountConfig step). The required fields are clientId, clientSecret, botId, and webhookSecret; botName and allowFrom are recommended.
{
"channels": {
"cliq": {
"enabled": true, // optional — omit or true keeps the channel running
"clientId": "<OAuth client id from step 3a>",
"clientSecret": "<OAuth client secret from step 3a>",
"botId": "openclaw_agent", // Bot Unique Name from step 1
"botName": "OpenClaw Agent", // Bot display name from step 1
"webhookSecret": "<secret from step 2>",
"refreshToken": "<refresh token from step 3c — required for channel posts / edits>",
"allowFrom": ["<zoho user id of each allowed DM sender>"],
// See the multi-user privacy warning below: session.dmScope is global
// and must be per-channel-peer for any bot several people can DM.
"dmPolicy": "allowlist", // "open" | "allowlist" | "pairing" | "disabled"
"groupPolicy": "disabled", // "open" | "allowlist" | "disabled"
"groups": { "dev-team": {} },
"trustedOrganization": {
"acknowledged": true,
"label": "Pay-Jet"
},
"streaming": { "mode": "partial" }, // default; set "off" for a single final reply
"thinking": {
"mode": "placeholder",
"animate": "dots"
}
}
},
"agents": {
"defaults": {
"blockStreamingDefault": "on" // fleet/global Core default; Cliq preview-on also asks per turn
}
},
"session": {
// REQUIRED for any bot more than one person can DM — see the warning below.
"dmScope": "per-channel-peer"
}
}
⚠️ Multi-user privacy:
dmPolicyandallowFromdo NOT isolate conversations.Those two settings control admission — who may contact the bot. They say nothing about isolation — whether those people share one conversation. Isolation is controlled by the separate, global
session.dmScope.
mainis the runtime default whenever thesessionblock is absent, so this affects fresh installations, not only upgrades from an older config. WithdmScope: "main"every DM collapses intoagent:<agentId>:main, across senders and across channels. Two things follow:
- Context leakage — one Cliq user's conversation history can appear in another user's turn.
- Cross-channel misrouting — the shared session's latest delivery route may point at a different channel, so a message can be received and processed while the reply is never delivered where it arrived (the gateway logs
visible channel turn dispatched with no queued reply payloads).Set the scope for any bot several people can reach:
{ "session": { "dmScope": "per-channel-peer" }, "channels": { "cliq": { /* existing Cliq configuration */ } } }Sessions are then isolated per channel and per sender (conceptually
agent:main:cliq:direct:<cliq-user-id>). For deployments with several configured Cliq accounts, use the stricterper-account-channel-peer, which additionally isolates the same sender across accounts. Restart the gateway after changing it — it is read at startup.
openclaw cliq doctorwarns when a multi-user Cliq bot still resolves tomain, andopenclaw security auditreports it as a critical finding (channels.cliq.session_scope.shared). Setup asks before changing it, because the value is global to every channel and must not be overwritten silently.
The default account is configured directly under channels.cliq, as shown above. Do not put it under accounts.default: the plugin treats the reserved id default as the top-level account. For multiple Cliq accounts, keep the default credentials at the top level and add only named secondary accounts under channels.cliq.accounts.<accountId>.
Inbound webhook traffic is resolved against the root account only. The gateway registers a single
POST /cliq/webhookroute and authenticates plus dispatches it withresolveCliqConfig(cfg, null)— there is no header, path, or payload multiplexing intochannels.cliq.accounts.<id>. Named accounts are currently outbound/diagnostics-only (sendText({ accountId }),openclaw cliq doctor --account <id>). Running multiple conversational bots requires separate gateway deployments (one per bot): each bot needs its ownbotId,webhookSecret, public URL, and handlers. See Running multiple agents. A second bot configured only as a named account on one gateway will fail admission (wrong secret / bot identity) or land in the default account's agent.
Secret representation (clientSecret, webhookSecret, refreshToken)
The three sensitive fields accept three interchangeable representations, at the channel root and inside channels.cliq.accounts.<id>. All three are accepted identically on every supported OpenClaw version (2026.8.2 and later) — there is no version-gated fallback:
{
"channels": {
"cliq": {
// 1. Canonical structured SecretRef — what `openclaw secrets apply` writes.
"clientSecret": { "source": "env", "provider": "default", "id": "CLIQ_CLIENT_SECRET" },
// 2. Environment interpolation.
"webhookSecret": "$CLIQ_WEBHOOK_SECRET",
// 3. A literal value (discouraged — the security audit flags it).
"refreshToken": "1000.abc..."
}
}
}
A SecretRef requires all three of source, provider, and id; source is one of env, file, or exec. provider must match ^[a-z][a-z0-9_-]{0,63}$ and, for env, id must match ^[A-Z][A-Z0-9_]{0,127}$. Anything else is rejected by config validation rather than silently accepted.
At runtime the plugin resolves plaintext, $NAME / ${NAME} env shorthands, and env-backed refs synchronously, matching the bundled Telegram channel. Shorthand strings follow the same secrets.defaults.env and provider-allowlist rules as structured SecretRefs; a missing or empty environment value is reported as unresolved rather than using the placeholder text as the credential. file and exec refs are reported by openclaw cliq doctor as configured-but-unresolved rather than being silently treated as absent.
To migrate literal secrets out of openclaw.json:
openclaw secrets audit # lists every plaintext Cliq secret, root and per-account, by path
openclaw secrets configure # map each one to a provider-backed SecretRef
openclaw secrets apply # rewrite config in place; values are never printed
openclaw cliq doctor reports each secret as resolved, unresolved, referencing an unavailable provider, plaintext, or absent — identified by source:provider:id only, never by value.
The plugin defaults to the EU data center. For another region, add both oauthBase and apiBase at the same account level; see Data centers.
For multiple gateway deployments, see Running multiple agents before reusing credentials: the OAuth app can be shared, but each agent requires its own bot identity, webhook secret, public URL, and handlers.
Every field except the required ones has a sensible default; groups / thinking / pairing are nested objects (see their descriptions).
-
enabled(optional) — Channel-level on/off switch, matching the shape of bundled channels (channels.telegram.enabled). Omitted ortruekeeps the channel running;falsekeeps the credentials in place but stops the account from starting. Named accounts underchannels.cliq.accounts.<id>can override this with their ownenabled. This is not the same key asplugins.entries.cliq.enabled: that OpenClaw-owned switch unloads the plugin entirely, so achannels.cliq.enabled: truecannot re-enable a plugin that was turned off there. The setup wizard writeschannels.cliq.enabled: true. -
clientId(required) — OAuth client id from the Zoho API Console. -
clientSecret(required) — OAuth client secret (sensitive). -
botId(required) — Bot Unique Name (the path segment in the bot message API), not Zoho's internalb-...bot ID. Bot and handler provisioning CRUD requires that separate internal ID after resolving the unique name; see the verified provisioning API contract. -
botName(recommended) — Bot display name. Used to strip the@botNamemention from the text the agent sees. -
ownSenderIds(optional) — Extra identities that belong to this bot, most importantly Cliq's internalb-…id. Channel posts from a bot arrive with that internal id, not the unique name. Put those values here so they count as self and as this bot in mention/reply directedness checks. Do not put other workspace bots here. -
selfSenderIds(optional) — Extra sender ids / names / emails whose inbound messages are dropped as "self" before dispatch. Use this for other workspace bots (bot-to-bot loop prevention) and, if needed, a zuid that is not already listed inownSenderIds. A listed bot is still dispatched when it explicitly @mentions this bot. A mention of or reply to a listed other bot is not treated as directed at this bot (#285). -
webhookSecret(required for inbound delivery) — High-entropy shared secret the Deluge handler sends in thex-cliq-webhook-secretheader. If unset or unresolved,/cliq/webhookfails closed with503and never dispatches an agent turn. A missing or wrong request header returns401. -
publicWebhookUrl(optional) — The public HTTPS URL Zoho posts to, e.g.https://cliq.example.com/cliq/webhook. Recorded byopenclaw setupso it can verify inbound delivery, and used to identify whetheropenclaw cliq webhook-preflight <url>is checking this install (and may record its result) or an unrelated endpoint (which must stay read-only). It is also reused by the public-webhook stage ofopenclaw cliq doctor. When the field is missing, the doctor can name the URL both Zoho handlers already agree on;openclaw cliq doctor --adopt-handler-urlstores that verified URL andinboundVerifiedAtafter a passing preflight. It does not change routing (the gateway always serves/cliq/webhook); it only tells the tooling which public URL belongs to this install. See Expose the webhook publicly. -
inboundVerifiedAt(written by setup or the preflight CLI) — ISO timestamp of the last successful public webhook verification. Written byopenclaw setupwhen the preflight passes, and also byopenclaw cliq webhook-preflight <url>when the checked URL is the configuredpublicWebhookUrl(pass--no-writefor a read-only probe); cleared when a later check proves a permanent failure, but preserved when bounded startup/readiness retries end inconclusively. Not read at runtime. -
inboundVerificationFailedAt(written by setup or the preflight CLI) — ISO timestamp of the last proven failed public webhook verification. Set when a check against the configuredpublicWebhookUrlproves a permanent failure and cleared by a later passing one, so status output can distinguish "never checked" from "last check failed" instead of folding both into "NOT verified". Transient/inconclusive runs preserve this andinboundVerifiedAt. Not read at runtime. -
inboundCatchup(optional, default off) — A bounded safety net for a direct-message webhook delivery that was missed even though the message exists in Cliq. With{ "enabled": true, "limit": 50 }, the next admitted DM fetches at most 50 recent records fromGET /api/v2/chats/{chatId}/messages, first verifies the current live message as a same-sender/same-text anchor, then dispatches only native-id records strictly between the persisted opaque per-conversation cursor and that anchor, oldest first. First enablement only stores the current anchor — it never imports an unbounded existing chat backlog. A missing cursor, absent anchor, failed read, missing refresh token, group/channel turn, malformed history record, self/bot record, or existing dedupe claim yields no recovery and never breaks the live inbound. The cursor is a mode-600 local state record keyed by SHA-256 of account/chat ids; it contains no message content. Requires a user-contextrefreshTokenwithZohoCliq.Messages.READ; Doctor reports enabled catch-up as unavailable when that credential is absent. Catch-up is event-triggered only — it neither schedules polling nor enables the Cliq MCP/agent message-read action. See Chat-history recovery. -
refreshToken(recommended) — User-context OAuth refresh token (sensitive). Obtained once via the self-clientauthorization_codeflow (§3c). Required for channel @mention replies, live-edit message edits, and native v3 typing — without it, those paths fail withoauthtoken_scope_invalid(the runtime profile uses refresh-token consent forZohoCliq.Channels.UPDATE,ZohoCliq.Messages.UPDATE, andZohoCliq.Chats.UPDATE). DM-only setups can leave it unset. -
heartbeat.typing(optional, default"dm") — Controls native v3 typing activities:"off"disables them,"dm"permits only remembered or explicitly addressed direct chats, and"all"also permits channels/groups. Cliq attributes every activity to the human who ownsrefreshToken; there is no supported bot/sender override, andbot_unique_nameis rejected. Use"all"only when that human attribution is deliberate. Ambiguous rawCT_…targets fail closed under"dm"; an inbound chat record or explicitcliq:user:…target must prove that the chat is direct. This setting does not affect visible bot-owned thinking/progress messages. -
ackPolicy(optional) — When the webhook acknowledges Cliq relative to the inbound dispatch."after_dispatch"(default) awaits the full dispatch before sending HTTP 200 — a crash mid-dispatch means Cliq never sees the 200 and redelivers (no lost message), and it works on every supported OpenClaw version."immediate"acknowledges first (faster, but a crash after the ack can lose the message). Deluge timeout gotcha: Zoho'sinvokeUrlin the bot Message handler has a ~40 s hard timeout. With"after_dispatch", a slow turn (image analysis, cold model) can trip Deluge's "The task has been terminated since the API call is taking too long to respond" even though the reply is delivered out-of-band; Deluge may then redeliver while the original turn is still running, and #123 prevents that redelivery from becoming a spurious "Couldn't process that message" placeholder."immediate"is the escape hatch when that timeout is unavoidable: on the supported runtime (>= 2026.8.2) this plugin wraps the continuation in the SDK'srunDetachedWebhookWorkbefore writing the 200 (#122); without that wrapper the healthy gateway refuses the post-ack turn withGatewayDrainingErrorand the user sees "Couldn't process that message". Prefer"after_dispatch"when turns stay below the Deluge timeout; use"immediate"for slower turns only when you accept the crash-after-ack loss risk. Pairs naturally withthinking.mode: "placeholder"(the placeholder posts immediately while the agent works). -
allowFrom(optional) — Array of Zoho Cliq user ids allowed to DM the bot (only effective whendmPolicyisallowlistorpairing). This is an admission control, not session isolation; multiple allowed senders still share context unless globalsession.dmScopeisper-channel-peer(orper-account-channel-peer). -
dmPolicy(optional) — DM admission policy, not conversation isolation (see the multi-user privacy warning above). Default isallowlist(deny by default).pairingstarts the OpenClaw pairing approval flow for unknown senders — by default the sender gets a reply with a pairing code and the bot owner runsopenclaw pairing approve cliq <code>on the CLI; setpairing.notifyOwnerTarget(see thepairingrow below) to instead post an Approve/Deny card to the owner so approval happens inline in Cliq. Accepted values:open,allowlist,pairing,disabled(schema-validated — unknown field names likedmSecurityare rejected). -
groupPolicy(optional) — Group/channel admission policy. Fresh generic setup defaults todisabled;openlets the bot respond in any channel where it is @mentioned,allowlistrestricts it to channel unique names listed undergroups, anddisabledignores all group messages. Existing configurations withoutgroupPolicyretain the legacy open behavior and are never rewritten during upgrades. -
groups(optional) — Per-channel config keyed by the Cliq channel unique name. Setup resolves entered channel names through the existing directory adapter. Each entry supportsrequireMention(boolean —falselets the bot respond in that channel without an explicit @mention; that always-on path also needs a provisionedparticipation_handler— see learning 156),ingest(boolean),tools({ allow, alsoAllow, deny }tool policy for that channel), andtoolsBySender(per-sender tool policy overrides keyed bychannel:cliq:<senderId>,id:<senderId>,name:<display>,e164:<phone>,username:<handle>, or*). A*entry applies to any channel not listed explicitly. On an always-on channel the plugin does not post a thinking placeholder for unmentioned chatter: if the model answers with Core's exactNO_REPLYtoken, the turn stays completely silent (no⚠️ Couldn't process that message.bubble). Directed@mentions, replies to the bot, and DMs keep the placeholder. List this bot's internalb-…id inownSenderIdsand other workspace bots inselfSenderIdsso their unmentioned posts never start a ping-pong loop (#283, #285). A mention of another bot is not a mention of this bot. -
trustedOrganization(optional, explicit acknowledgement) — Set by setup only after the operator confirms organization-wide exposure. This metadata is never inferred fromallowFrom: ["*"]ordmPolicy: "open"; existing open configurations remain unchanged.acknowledged: truemakes the security audit classify the deliberate wildcard/open policy as informational, but does not enforce organization membership. Cliq webhook payloads may includeuser.organization_id, yet Deluge forwards it as ordinary JSON and the directory API provides no independent tenant proof. The actual boundary is the constant-time verifiedx-cliq-webhook-secretplus the installed bot-handler context. The wizard displays the effective DM/group access and tool-policy caveat before recording acknowledgement. -
thinking(optional) — Instant acknowledgement / "thinking" placeholder.What you see. New installs post an animated placeholder by default (
thinking.mode: "placeholder",thinking.animate: "dots"— cycles💭 .→💭 ..→💭 ...while the agent works). That bubble is the same message the reply later edits in place — not a second progress surface. A "missing" reply is often that bubble still showing💭 …because the Cliq client has not redrawn the edit.Turn it off. Set
thinking.mode: "off"so no placeholder is posted. To also get a fresh final message instead of an in-place replace of that bubble, setstreaming.mode: "off".Modes.
thinking.modeselects the acknowledgement style:"placeholder"(default — post a lightweight text placeholder, default💭 …, configurable viathinking.text, then edit it in place into the final reply — exactly one message, no duplicate),"card"(post a v3 Message Card status indicator — amodern-inlinecard — instead of plain text, and transition its title through explicit phases as the turn runs: it is first posted with the "thinking" phase titlethinking.thinkingText, default💭 thinking…, then edited in place to the "generating" phase titlethinking.text, defaultGenerating…, right before the agent turn dispatches, and finally edited into the reply text when the reply arrives), or"off"(no extra API call per turn). OnapiVersion: "v3"the card is a real card posted via the v3 Message Card endpoints (DM viaPOST /api/v3/bots/{botId}/messageswith scopeWebhooks.CREATE, channel viaPOST /api/v3/channels/{name}/messagewith scopeChannels.CREATE); on v2 it degrades to the plain-text placeholder since v2 has no buttonless card. Native v3 typing (POST /api/v3/chats/{chat_id}/activities) is separate and posts as the refresh-token owner, not as the bot; this bot-owned placeholder remains the safe visible progress surface for shared rooms.Animation.
thinking.animatecycles the placeholder through text frames on an interval while the agent turn has not produced a real draft update:"dots"(default —💭 .→💭 ..→💭 ...),"spinner"(braille-spinner frames prefixed with a fixedthinking…label),"custom"(cyclesthinking.animateFrames, a string array — needs ≥2 non-empty entries or it degrades to the static placeholder), or"off"(static placeholder). The interval isthinking.animateIntervalMs(default 1200 ms, hard-floored to 800 ms to protect the Cliq edit rate limit) and the total animation duration is capped (default 60 s — past the cap the animation stops and holds the last frame so a long turn does not hammer the edit endpoint). The animation reuses the existingeditMessagepath (Messages.UPDATE, samerefreshTokenprecondition as the placeholder itself); a failed frame edit stops the animation but never breaks the turn. Withstreaming.mode: "partial","block", or"progress", animation covers the otherwise silent tool/reasoning phase, then stops permanently at the first substantive preview/progress/final update. The stop drains an in-flight frame PUT before that real update is applied, so a late frame cannot overwrite the growing draft. No new OAuth scope.Confirmation gate (card-mode only): set
thinking.confirmto gate sensitive inbound actions behind an explicit Confirm / Cancel button card instead of dispatching immediately."off"(default) disables gating;"sensitive"gates only when the cleaned message matches athinking.confirmKeywordsentry (case-insensitive word-boundary match; defaults to a conservative destructive-verb list —delete,drop,reset,wipe,purge, …);"always"gates every turn (apart from abort intents and Confirm-button re-dispatches). When gated, aprompt-theme Message Card titledthinking.confirmText(default⚠️ Confirm action?) withthinking.confirmLabel/thinking.cancelLabelbuttons (defaultsConfirm/Cancel) is posted and the agent turn is held until the user taps a button. Confirm re-posts the original message (prefixed with a sentinel) so the next webhook call dispatches the agent with the gate skipped (no re-prompt loop); Cancel posts a sentinel that short-circuits the turn withthinking.cancelledText(default🚫 Cancelled.) and no agent dispatch. The button clicks arrive as ordinary inbound messages via the bot's Message handler (invoke.bot) — no Cliq Context handler is required. Messages longer than 1500 chars bypass the gate (cannot be safely encoded in the confirm button payload). The gate is a UX guardrail, not a security boundary — the agent's own tool / permission policy still applies to the confirmed action. No new OAuth scope (reuses the card-path scopes).Requirements. Any acknowledgement mode is a no-op when no
refreshTokenis configured (editing the placeholder into the reply needs the user-context token).thinking.failureText(optional) overrides what the placeholder/card becomes when a directed agent turn ends with no reply (the turn threw, or the model produced no output): when set, the placeholder is edited into that text (e.g.⚠️ No reply generated.); when unset, the default is⚠️ Couldn't process that message.Unmentioned always-on channel turns (requireMention: false+participation_handler) never post this placeholder in the first place, so a CoreNO_REPLYsilent turn cannot leak a failure bubble into the room (#283). -
streaming(optional) — Block-streaming live-edit of one Cliq response. This is not Telegram-style token streaming.What you see on a default install.
streaming.modedefaults to"partial"when unset. Existing legacystreaming.preview: "on"also resolves to"partial";preview: "off"resolves to"off". The thinking placeholder, when present, is the same message the final reply edits in place. A 2026-08-29 production DM (Mara, defaults,refreshTokenpresent — issue #195) posted a 4-character placeholder (textLen=4), sat through ~30s of model + tool work with no intermediate PUTs, then applied one final edit (textLen=117, HTTP 204). #175 and #185 never documented a live Cliq DM round-trip; that Mara miss is the first production evidence.Two switches, not one. OpenClaw block streaming, Cliq one-message live-edit, and thinking placeholder animation are different layers:
Switch Layer What it does agents.defaults.blockStreamingDefaultOpenClaw block streaming Fleet/global Core default. Affects channels or turns that do not set a per-turn override. channels.cliq.streaming.modeCliq one-message live-edit Selects "off","partial","block", or"progress". The current default is"partial"; any non-"off"mode asks Core for block/partial delivery on that Cliq turn even if the global default is off."progress"is an explicit opt-in: Core tool/item/plan events update one compositor-owned draft, andonPartialReplyis not used for that mode.thinking.animatethinking placeholder animation Cycles placeholder frames during the silent phase; streaming/progress takes ownership at its first substantive draft update. Setting only
agents.defaults.blockStreamingDefault: "on"is not enough to make a terminal-held model grow, and no config switch can invent text the provider/Core holds until the end of the turn.Working streaming configuration. Verified live on Mara and Pay-Jet Franzi (plugin
7ab02f9/ 0.1.10, OpenClaw2026.8.1-beta.3,refreshTokenpresent — issue #205):{ "agents": { "defaults": { "blockStreamingDefault": "on" } }, "channels": { "cliq": { "refreshToken": "<user-context refresh token from §3c>", "streaming": { "mode": "partial" }, "thinking": { "mode": "placeholder", "animate": "off" } } } }What each mode actually means. The four modes are different lanes, not four intensities of the same thing:
Mode What drives the visible draft What it is not "progress"Core work events (tool / item / plan / approval / command / patch / narration) update one draft while the turn runs. It never depends on answer snapshots, so a model that emits no intermediate text still shows visible work. Not an answer preview — the answer arrives only as the final message. "partial"Core answer previews ( onPartialReply) update the draft, and only when the model/runtime actually emits them.Not a work log — a terminal-held model shows nothing until the final. "block"Core block semantics: each flushed reply block is folded into the same draft. Not token streaming. "off"Nothing intermediate; the final reply is delivered normally. Not a fallback for the modes above — it disables live-edit entirely. Progress-mode configuration. To show Core work events on a model that holds its answer text until the end:
{ "channels": { "cliq": { "refreshToken": "<user-context refresh token from §3c>", "streaming": { "mode": "progress" }, "thinking": { "mode": "placeholder", "animate": "off" } } } }Modes and legacy compatibility.
"partial"preserves the current answer-preview behavior;"block"selects Core's block preview shape;"progress"opts into the Core progress-draft compositor (createChannelProgressDraftCompositor) and its configuration surface (streaming.progress.toolProgress,commentary,narration,commandText,label/labels,maxLines,maxLineChars);"off"disables live-edit. In"progress"mode, CoreonToolStart/onItemEvent/onPlanUpdate/onApprovalEvent/onCommandOutput/onPatchSummary/onNarrationUpdatecallbacks drive one draft, and answer snapshots cannot overwrite that draft before final delivery. Reasoning progress is forwarded only when the existing OpenClawreasoningDefault/ sessionreasoningLevelis"stream". The plugin reads these settings with OpenClaw's shared streaming helpers instead of maintaining a second parser or defaults. Legacystreaming.preview: "on" | "off"remains loadable and maps to"partial" | "off"; an explicitstreaming.modewins if both keys are present. Runopenclaw doctor --fixto rewrite legacy root and per-account config safely.What
progressrenders. Core owns the status headline, plan checklist, sanitized rolling lines, line identity/order, andmaxLines/maxLineCharstruncation. Cliq converts that composed Markdown once withmarkdownToCliq, caps the visible draft at 5000 characters, and reuses a text thinking placeholder when present; otherwise it creates one editable text message after Core's start gate. Updates are throttled and coalesced bystreaming.minEditIntervalMs, while Core'sflushrequests bypass the wait. A fitting final answer replaces the same message even when it is shorter; a long final uses that message for the first chunk and normal messages for overflow. If final replacement fails, the complete final is sent normally and stale-draft deletion is best-effort because Zoho may reject deletion of bot messages. Card-only replies retire an editable text draft with the existing placeholder fallback; no-reply errors replace it with failure text, and benign skips retire it to a minimal marker. Progress drafts stay off for v2 DMs, v3 channel posts, andthinking.mode: "card"because those surfaces cannot be converted into editable text in place. This requires arefreshTokenwithZohoCliq.Messages.UPDATE.Guarantee. Same message plus in-place final edit when message ids and
ZohoCliq.Messages.UPDATEare available. Progressive intermediate growth only when the selected model/runtime emits intermediate text — that caveat is about answer text, so it applies to"partial"and"block". In"progress"mode the intermediate content is Core work events, so visible movement does not depend on the model emitting answer snapshots.Live model matrix (same host Mara, same plugin, same OpenClaw, same 80-sentence prompt, both switches on,
thinking=medium):Model Shape sprintcx/tier-1+thinking=mediumsend textLen=4→ (46 s, no intermediate PUT) → oneedit textLen=4097. This path can stampopenclawDelivery.textPhaseRequiresTerminaland return beforeemitAssistantStreamData(openclaw/openclaw#132615).sprintcx/tier-2+thinking=mediummonotonic growth textLen=4→textLen=68→textLen=1269→textLen=2075→textLen=2878→textLen=3493→textLen=4187→textLen=4942. Pay-Jet Franzi independently showed the same shape.OpenAI-completions +
thinkingis therefore not a universal snapshot block. Plugin unit tests that callonPartialReplythemselves do not prove the runtime emits it. Coalesced blockdeliver()calls can still edit the same message when Core flushes a block.Snapshots are not always emitted, and the first one can be tiny. A 2026-08-30 Mara DM (issue #203) showed two identical prompts taking different shapes on the same host and model: one jumped straight from the placeholder to the final answer, the other emitted snapshots starting at
textLen=1. Snapshots that carry no more content than the placeholder text itself are ignored, so a 1-character draft never replaces the acknowledgement bubble.HTTP 204 is API acceptance only. Zoho can accept the final
PUT /api/v2/chats/{chatId}/messages/{messageId}while the Cliq desktop/app still shows⏳ …until the chat is reopened. Native v3 typing is different: a second-account observation proved that accepted activity is visible but attributed to the human refresh-token owner. That is whyheartbeat.typingdefaults to DMs and shared-room progress uses bot-owned editable messages. Live-edit still needs the redraw caveat.Opt out. Set
streaming.mode: "off"for a single final reply instead of an in-place replace. Combine withthinking.mode: "off"if you do not want the thinking bubble at all. The legacystreaming.preview: "off"remains accepted until migrated by doctor.Mechanics. The thinking animator (
thinking.animate) may update the shared placeholder while preview/progress is still silent. Before the first substantive draft update, the inbound path stops the timer and drains any frame edit already in flight; every later edit belongs only to live preview/progress, so animation cannot clobber generated text. Live-edit needsrefreshToken(ZohoCliq.Messages.UPDATE); without it, previews degrade to one normal final response andopenclaw cliq doctorsays so. Edits are throttled and coalesced (streaming.minEditIntervalMs, default 1000) so unchanged or older content is never sent, and a 429 backs off before retrying the latest preview. Edit failures fall back to one normal final response without failing the turn. Message edit stays on the v2PUT /api/v2/chats/{chatId}/messages/{messageId}path (ZohoCliq.Messages.UPDATE); v3 has no single-message edit endpoint. -
welcome(optional) — Welcome message on subscribe. When the Cliq bot Welcome Handler forwards a subscribe event to the webhook (see §5a) andwelcome.enabled === true, the bot posts a configurable greeting DM to the subscriber.welcome.textis used for first-time subscribers andwelcome.textRejoinfor users who unsubscribed and came back; both default to a friendly greeting and support{{firstName}}/{{lastName}}/{{name}}/{{id}}/{{email}}placeholders resolved from the forwardeduserobject. The DM admission policy (dmPolicy/allowFrom) is honored — a denied sender is never greeted, and under thepairingpolicy an un-paired subscriber is skipped (the pairing flow owns their first contact). Defaultenabled: false(opt-in, so no setup gets a surprise greeting). A redelivered subscribe event is deduped so the user is never greeted twice. -
pairing(optional) — Form-driven pairing approval (only effective whendmPolicyispairing). By default an unknown sender's pairing code must be approved on the CLI (openclaw pairing approve cliq <code>). Setpairing.notifyOwnerTargetto a Cliq route target —cliq:user:<zohoUserId>/user:<zohoUserId>/cliq:channel:<uniqueName>/channel:<uniqueName>(a bare string is treated as a DM user id) — and the pairing flow additionally posts an approval prompt card there (aprompt-theme Message Card with Approve/Denyinvoke.botbuttons) carrying the sender id + pairing code. The configured owner taps Approve to admit the sender (the plugin records the sender in its persistent approval store and, where available, also writes through to OpenClaw's SDK allow-from store) or Deny to replydeniedOwnerText(the pending request is left in place; the sender is re-challenged idempotently if they message again). Only the exact DM identity configured bypairing.notifyOwnerTargetmay approve or deny; comparison is case-insensitive, and any other sender — including the person requesting access with their own valid code — is rejected without revealing whether the code was valid. Because a channel target identifies a room rather than a person, channel targets cannot securely prove which member clicked and their button actions are rejected; configure a user/DM target for inline approval. Button approval works on every supported OpenClaw version through the plugin-owned store. The CLI step (openclaw pairing approve cliq <code>) works on every supported version and remains available alongside the card. Button approvals are persisted per account in<state-dir>/cliq/pairing-store.json(sender id, approving owner, timestamp — never the pairing code); removing the account clears its records, and pairing codes are single-use and expire after one hour. RequiresbotId(the v3invoke.botbutton renderer drops buttons without one); when absent ornotifyOwnerTargetis unset, the card is skipped and only the CLI path is used. The button click arrives as an ordinary inbound message and is short-circuited before the mention / admission gates (so the owner need not be on the allowlist to approve). Optional overrides:approveLabel/denyLabel(button labels, defaultApprove/Deny),approvalTitle(card title, default🔐 Pairing request),approvedOwnerText(owner reply on approve, default✅ Approved.),deniedOwnerText(owner reply on deny, default🚫 Denied.). No new OAuth scope — the approval card reuses the same card-path scopes (Webhooks.CREATEfor DM cards viaclient_credentials;Channels.UPDATEon v2 /Channels.CREATEon v3 for channel cards) the existingmessage(action=send, buttons=…)path uses. -
oauthBase(optional) — OAuth base URL for your Zoho data center. Defaults to the EU endpointhttps://accounts.zoho.eu. Set it (together withapiBase) when your account is not on EU — see Data centers. -
apiBase(optional) — Cliq REST API base URL for your Zoho data center. Defaults to the EU endpointhttps://cliq.zoho.eu. Set it (together withoauthBase) when your account is not on EU. -
apiVersion(optional) — REST API generation for the channel text post, bot DM post, message delete, and channel card/button post families. Accepts EITHER a string global override OR a per-family object:- a string
"v2"forces ALL migratable families to the verified-live v2 endpoints;"v3"forces all to v3. - an object
{ dmPost?, channelPost?, channelCard?, delete? }overrides per-family; any family left unset falls back to the built-in default.
Built-in per-family defaults (the sole family where v3 is strictly better flips to v3; the rest stay v2). Omitting
apiVersionentirely yields these defaults — you no longer need to setapiVersion: "v3"for the DM placeholder / live-edit to work (a previous manifestdefault: "v2"bug silently forced all families to v2; fixed in issue #86).dmPost→"v3"(the win). The v3 "Send a bot message" endpointPOST /api/v3/bots/{botId}/messages(scopeZohoCliq.Webhooks.CREATEviaclient_credentials— no refresh token needed, posts as the bot) sends the recipient key asuserids(v2-style, NO underscore — auser_idskey is rejected withextra_key_found) andsync_message: true, so the response carries the sentmessage_id+chat_idundermessage_details.<userId>(URL-encoded — decoded once and the v2 edit URL re-encodes exactly once). That id is what thethinking.mode: "placeholder"acknowledgement and DM live-edit-in-place need to edit the reply in place; the v2 DM endpoint returns no message id, so on v2 DMs those features leave an orphaned💭 …. Some orgs may additionally require theZohoCliq.BotMessages.CREATEscope for the v3 bot endpoint — if your org rejects the v3 DM post, restore the v2 path withapiVersion: { dmPost: "v2" }(or the string"v2").channelPost→"v2". v3 (POST /api/v3/channelsbyname/{name}/messages,Webhooks.CREATE) posts as the bot but returns no message id (live-edit for channel posts degrades to block-streaming — no win over v2). Flip to"v3"only after verifying the win live.channelCard→"v2". The v3 Message Card channel endpointPOST /api/v3/channels/{name}/message(note:channels, notchannelsbyname, singularmessage; scopeZohoCliq.Channels.CREATE, refresh-token grant) has nobot_unique_namequery param, so a v3 channel card posts as the authenticated user (not the bot) — a sender-identity regression. v2 channel cards (channelsbyname/{name}/message?bot_unique_name=,Channels.UPDATE) post as the bot. The v3 path renders amodern-inlineMessage Card body whosethemeis selected by the card sender (modern-inlinefor agent-emitted presentations and themessage(action=send, buttons=[...])tool; themessage(action=send, slides=[...])tool attaches structuredtable/list/label/images/textsupporting-content blocks via the theme-independent top-levelslidesarray; themessage(action=send, thumbnail="https://…", sections=[{ title?, fields: [{ title, value }] }])tool attaches amodern-inline-only headerthumbnailimage (HTTPS URL) and in-card labeled fieldsections(both ignored forprompt/poll);promptfor the slash-command quick-reply buttons emitted by the/modelsand/modelmenus — a focused quick-reply card with a title + 1–5 action buttons, no sections;pollfor voting cards emitted by themessage(action=send, theme="poll", pollOptions=[...])tool — a title + 2–10 plain-text options, no buttons; Cliq counts votes natively, so a poll does NOT post anything back to the bot). DM card/button posts in v3 route through the samePOST /api/v3/bots/{botId}/messagesendpoint as v3 DM text posts, with a top-levelcardfield — sameWebhooks.CREATEscope (client_credentials, no refresh token), posting as the bot and addressing the recipient viauserids; thepolltheme works for DM cards too.delete→"v2". The v3 bulk-delete endpointDELETE /api/v3/chats/{chatId}/messagess?message_ids=<id>(a 1-element delete-multiple call; scopeZohoCliq.Messages.DELETE, refresh-token grant) offers no functional win over the v2 single-message delete (Messages.UPDATE, refresh-token grant). The v3 delete response is a per-messagemessage.delete_resultlist parsed into a boolean.
Locked families (message edit, reactions, media posts, directory listing, file download, channel-chat-id resolution, message list) stay on
/api/v2/...regardless ofapiVersion— v3 has no endpoint for them (confirmed against the v3 REST docs). A v3-posted DM is edited via the v2 edit endpoint (PUT /api/v2/chats/{chatId}/messages/{messageId},Messages.UPDATE) — that hybrid is intended. v3 channel posts return no message id, so live-edit for channel posts degrades to block-streaming. v3 DMs withsync_message: truedo return the message id. Per-account overrides are supported, so one account can pilot a different family mix while others stay on v2. - a string
Group/channel identity: the inbound path sets the OpenClaw From context field to cliq:group:<channelUniqueName> for group messages (and fills GroupChannel/GroupSubject with the display name as a fallback), so the groups adapter resolves per-channel requireMention and tool policy by channel unique name. Keys are matched case-insensitively.
Gateway reachability: the host running the OpenClaw gateway must be reachable from the public internet at https://<gateway-host>/cliq/webhook. If you run the gateway behind a reverse proxy / Cloudflare Tunnel, make sure TLS termination and the Host header are preserved. See Expose the webhook publicly for per-option instructions and the failure catalogue.
5. Deluge Webhook Handler
The Cliq bot must forward every mention / message event to the OpenClaw webhook. The two handlers use almost the same script, but they are not interchangeable — see the note after the script.
openclaw setupcan do this for you. WhenpublicWebhookUrlis configured and the OAuth client carries the provisioning scopes from §3b, setup shows a read-only dry-run of the Zoho-held handlers and then offers to create or repair them; a newly created bot includes Zoho's required non-empty description, and the Welcome handler is included only when the greeting is opted in. Nothing is changed without a separate confirmation that defaults to no. A handler whose URL matches but whose secret differs is reported as a conflict rather than "already configured" — that state passes the preflight while real inbound traffic fails with401. Unreadable and hand-written handlers are never overwritten, and every write is read back before it counts as successful. The steps below remain the manual path, and are still the correct route when you prefer not to grantZohoCliq.Bots.CREATE/ZohoCliq.Bots.UPDATE.
Do not provision handlers through the Zoho MCP server. Its
ZohoCliq_create_bot_handler/ZohoCliq_get_bot_handler/ZohoCliq_update_bot_handlertools fail with400 request_url_invalidregardless of bot-id format, while bot-level tools on the same server work. Useopenclaw setup, the manual paste below, or the direct REST endpoints in the provisioning API contract.
<p align="center"> <img src="https://raw.githubusercontent.com/sprintberlin/openclaw-cliq/main/assets/cliq-bot-handlers.png" alt="Zoho Cliq bot Edit Handlers page — Message Handler and Mention Handler highlighted" width="820"> </p>Where to find them: in the Cliq Bot editor open Edit Handlers, then click Edit Code on Message Handler (DMs) and Mention Handler (channel @mentions) — the two arrowed below.
// === Configuration (set these once) ===
// Public URL of your gateway's /cliq/webhook route. If you expose the gateway
// port directly (no reverse proxy / TLS) this is http://<host>:18789/cliq/webhook.
webhookUrl = "https://<gateway-host>/cliq/webhook";
webhookSecret = "<the same secret you set as webhookSecret in openclaw.json>";
// Cliq provides `message`, `user`, and `chat` in the Message/Mention handler
// scope. The plugin's parser accepts these Cliq objects as-is (it tolerates the
// different chat/channel key variants), so just forward them directly.
// Cliq passes `attachments` as Deluge FILE objects. Send the actual FILE
// objects as multipart parts so the gateway can stage them locally. Metadata
// on that branch is one flat scalar stringPart per field (v5): a nested
// `payload.toString()` part is a Deluge map literal whose unquoted grammar
// makes commas/brackets in captions ambiguous. Text-only messages keep the
// raw-JSON path below.
payload = Map();
payload.put("handler", "message"); // <-- use "mention" in the Mention Handler
// Generated-handler payload contract marker (issue #228). Keep this literal:
// `openclaw cliq doctor` reads it back to identify a stale Zoho-held script.
payload.put("handlerSchema", "v6");
payload.put("message", message);
payload.put("user", user);
payload.put("chat", chat);
// Per-execution event id (issue #196). Cliq's bot Message Handler exposes no
// native message/event id, so the handler mints one itself. It is created once
// per handler execution and reused if the same POST body is retried, so an
// exact webhook replay is still deduped while two deliberate identical
// commands (e.g. `/new` twice) stay independent turns.
eventId = zoho.currenttime.toString("yyyyMMddHHmmss") + "-" + randomNumber(100000,999999) + randomNumber(100000,999999);
payload.put("eventId", eventId);
// Message Handler only — the Mention Handler does not receive `attachments`,
// so this block must be removed there (see the note below the script).
if (attachments != null)
{
attachmentNames = List();
for each attachment in attachments
{
attachmentNames.add(attachment.getFileName());
}
payload.put("attachments", attachmentNames);
}
// Auth + content type. The secret header is REQUIRED when webhookSecret is set
// in openclaw.json; Content-Type MUST be application/json.
headers = Map();
headers.put("Content-Type", "application/json");
headers.put("x-cliq-webhook-secret", webhookSecret);
// POST text-only events by passing the Deluge Map directly so Zoho owns JSON
// escaping. For attachments, send one flat scalar stringPart per metadata
// field (handler, handlerSchema, message, eventId, userId, userName, chatId,
// chatType) plus every Deluge FILE object; invokeUrl creates the multipart
// Content-Type/boundary itself, so only the secret header is set on that
// branch. Some Deluge executions omit or mislabel that generated
// Content-Type, may serialize disposition parameters without double quotes,
// may use LF-only framing, and may omit the blank line between part headers
// and content; the gateway recognizes the bounded generated field parts from
// the body before parsing the attached FILE parts.
// A 2xx returns only eventId; non-2xx or a caught transport error replaces
// the response with a text-only Map containing the reference in the text.
// Zoho does not render text when the same response Map also has an eventId key.
// No retry or second Cliq API call.
response = Map();
response.put("eventId", eventId);
try
{
attachmentFiles = List();
if (attachments != null)
{
for each attachment in attachments
{
attachmentFiles.add(attachment);
}
}
if (attachmentFiles.size() == 0)
{
delivery = invokeUrl
[
url : webhookUrl
type : POST
body : payload
headers: headers
detailed: true
];
if (delivery.get("responseCode") < 200 || delivery.get("responseCode") > 299)
{
response = Map();
response.put("text", "Your message could not be processed (Ref " + eventId + "). Please send it again.");
}
}
else
{
requestFiles = List();
partHandler = Map();
partHandler.put("stringPart", "true");
partHandler.put("paramName", "handler");
partHandler.put("content", "message");
requestFiles.add(partHandler);
partSchema = Map();
partSchema.put("stringPart", "true");
partSchema.put("paramName", "handlerSchema");
partSchema.put("content", "v6");
requestFiles.add(partSchema);
partMessage = Map();
partMessage.put("stringPart", "true");
partMessage.put("paramName", "message");
partMessage.put("content", "" + message);
requestFiles.add(partMessage);
partEvent = Map();
partEvent.put("stringPart", "true");
partEvent.put("paramName", "eventId");
partEvent.put("content", "" + eventId);
requestFiles.add(partEvent);
partUserId = Map();
partUserId.put("stringPart", "true");
partUserId.put("paramName", "userId");
partUserId.put("content", "" + user.get("id"));
requestFiles.add(partUserId);
if (user.get("name") != null)
{
partUserName = Map();
partUserName.put("stringPart", "true");
partUserName.put("paramName", "userName");
partUserName.put("content", "" + user.get("name"));
requestFiles.add(partUserName);
}
partChatId = Map();
partChatId.put("stringPart", "true");
partChatId.put("paramName", "chatId");
partChatId.put("content", "" + chat.get("id"));
requestFiles.add(partChatId);
if (chat.get("type") != null)
{
partChatType = Map();
partChatType.put("stringPart", "true");
partChatType.put("paramName", "chatType");
partChatType.put("content", "" + chat.get("type"));
requestFiles.add(partChatType);
}
for each attachment in attachmentFiles
{
requestFiles.add(attachment);
}
delivery = invokeUrl
[
url : webhookUrl
type : POST
files : requestFiles
headers: {"x-cliq-webhook-secret":webhookSecret}
detailed: true
];
if (delivery.get("responseCode") < 200 || delivery.get("responseCode") > 299)
{
response = Map();
response.put("text", "Your message could not be processed (Ref " + eventId + "). Please send it again.");
}
}
}
catch (e)
{
response = Map();
response.put("text", "Your message could not be processed (Ref " + eventId + "). Please send it again.");
}
return response;
The script above is for the Message Handler (DMs —
handlervalue"message"). The Mention Handler (channel/group @mentions) is the same script minus the attachment-name and multipart branches and with thehandlervalue"mention"— i.e. use the simple raw-JSONinvokeUrlbranch and setpayload.put("handler", "mention"). Group vs DM is detected automatically from the forwardedchatobject, so no extra mapping is needed. The scripts cannot be byte-identical: the Mention Handler does not provide theattachmentsparameter, and a Mention Handler script that references it fails Zoho's script validation when the handler is saved via the provisioning API (execution_handler_update_failed— see the verified provisioning API contract). A legacy handler that forwards onlypayload.put("attachments", attachments)still lets the plugin try theMessages.READhistory lookup, but it is not sufficient for all voice-message deliveries: serialization can preserve only the displayed.wavname. Re-runopenclaw setuporopenclaw cliq provisionafter upgrading; the read-only planner detects the missing multipart branch and offers a confirmation-gated Message Handler repair. In live Cliq Message Handlers,messagemay be a bare string with nomessage.idormessage.time. TheeventIdline above is what gives the plugin a retry-stable identity for that shape. Message identity resolves in this order: Cliq's nativemessage.idwhen present, then the forwardedeventId(surfaced asMessageSidand given full 30-minute replay protection), then — only for older handler scripts that forward noeventId— a content-derivedsyn:hash whose dedupe identity expires after 60 seconds. Some handler configurations wrap the execution payload inparams; the plugin still readseventIdfrom that wrapper. Re-runopenclaw setupafter upgrading so its read-only handler plan detects a missingeventIdas a stale script and offers a confirmation-gated repair.openclaw cliq doctoralso fails a matching handler that still omitspayload.put("eventId")orresponse.put("eventId")(issue #231 — without the echo every Zoho execution row staysoutput: "{}"). Alternatively, re-paste both handler scripts manually to pick up the current generated contract.
⚠️ Security: the handler script exposes the webhook secret. The secret is a literal in the Deluge script, so it is readable by anyone who can edit the bot in the Cliq console and by any token holding
ZohoCliq.Bots.READ(GET /api/v3/bots/<bot-id>/handlers/<type>returns the stored script — the secret included — and creating a handler echoes it back in the response body, so terminal scrollback, logs, and diffs of that response capture a live credential). Zoho offers no secret storage for handler scripts, so this exposure cannot be closed on the OpenClaw side; it can only be contained:
- The blast radius is inbound dispatch for that one agent. Whoever reads the secret can post to
/cliq/webhookand drive that agent's turns. It grants no access to your Zoho credentials, OAuth tokens, or other channels.- Use a per-agent webhook secret (a distinct high-entropy
channels.cliq.webhookSecretper bot). This is the security reason for the practice: a leaked secret then compromises one agent, not all of them.- Rotate the secret whenever bot-edit access changes — when someone with bot-edit rights or
Bots.READconsent leaves, or when a handler script or provisioning response may have been captured. Rotation means updating both the Deluge script andchannels.cliq.webhookSecret.Bots.READis more privileged than it looks. Granting it for provisioning also grants "can read the inbound webhook secret of every bot in the org" — treat that consent accordingly.- The secret lives in two places, so they can drift. If the handler and gateway config hold different values, inbound delivery fails. The sixth
handler_secretpreflight stage reads Message/Mention scripts withZohoCliq.Bots.READand compares fingerprints only; when it is skipped, a green preflight proves the configured secret works against your endpoint but does not prove Zoho holds that same value.The plugin's own tooling never logs handler script bodies or raw provisioning API responses at any log level; this is enforced by a regression test.
Do not use
parameters: payload.toString(). In Deluge,invokeUrl'sparameters:key serializes the value as form-urlencoded data (handler=mention&message=...), which is not the JSON body this plugin expects — the gateway returns400 Unexpected token 'h', "handler=me"... is not valid JSON. Always usebody:together with theContent-Type: application/jsonheader shown above. Even on that canonical path, Zoho'sMap.toString()does not reliably escape quotes or newlines inmessageand may vary whitespace/order in the generated suffix; the webhook repairs recognized generated-handler bodies before parsing. Preserve the generated field names when maintaining handlers manually.
5a. Welcome Handler (optional)
The Cliq bot Welcome Handler fires when a user subscribes (or re-subscribes) to the bot. To greet new subscribers from OpenClaw config rather than hard-coding the message in Deluge, paste this script into the bot's Welcome Handler function (in the Cliq Bot editor → Edit Handlers → Edit Code on Welcome Handler). It forwards the subscribe event to the same /cliq/webhook endpoint the Message/Mention handlers use, with handler: "welcome" and Cliq's newuser boolean:
// === Configuration (set these once — same values as §5) ===
webhookUrl = "https://<gateway-host>/cliq/webhook";
webhookSecret = "<the same secret you set as webhookSecret in openclaw.json>";
payload = Map();
payload.put("handler", "welcome");
payload.put("user", user);
payload.put("newuser", newuser);
eventId = zoho.currenttime.toString("yyyyMMddHHmmss") + "-" + randomNumber(100000,999999) + randomNumber(100000,999999);
payload.put("eventId", eventId);
headers = Map();
headers.put("Content-Type", "application/json");
headers.put("x-cliq-webhook-secret", webhookSecret);
invokeUrl
[
url : webhookUrl
type : POST
body : payload
headers: headers
];
// Echo the eventId so a replayed subscribe event is correlatable in the
// Zoho Bot execution log instead of reading `output: "{}"` (issue #231).
response = Map();
response.put("eventId", eventId);
return response;
When welcome.enabled === true in the channel config, the gateway posts the configured greeting DM to the subscriber (see the welcome row in §4). The event is always acknowledged so Cliq does not redeliver it; a redelivery is deduped by subscriber id so the user is never greeted twice. The dmPolicy / allowFrom gate is honored — a denied sender is never greeted. Without this handler, or with welcome.enabled === false (the default), subscribe events are simply not consumed by the plugin.
5b. Form Handler (optional — structured input)
Zoho Cliq's platform Forms let you define a structured form (text / number / dropdown / date / … fields) that a user fills out from the bot's command surface. When a user submits a form, the bot's Form Handler Deluge script fires and can forward the submitted values to the OpenClaw webhook so the agent receives them as structured input rather than free text — useful for approval / collection flows (pairing approval, parameter capture) instead of asking the user to type free-text answers.
Paste this script into the bot's Form Handler function (in the Cliq Bot editor → Edit Handlers → Edit Code on Form Handler) — it forwards to the same /cliq/webhook endpoint the other handlers use:
// === Configuration (set these once — same values as §5) ===
webhookUrl = "https://<gateway-host>/cliq/webhook";
webhookSecret = "<the same secret you set as webhookSecret in openclaw.json>";
// Cliq passes the submitted values to the Form Handler scope as the `form`
// object's fields (each named after your form field). Read them out and
// forward them as a `values` map the plugin can parse.
payload = Map();
payload.put("handler", "form");
payload.put("form", { "name": "approval_request" }); // your form's name
payload.put("user", user);
payload.put("chat", chat);
// Build the submitted-values map. Replace the keys with your own form's
// field names; Cliq passes each field value as a Deluge variable named after
// the field. Example fields: approver, priority, reason.
values = Map();
values.put("approver", approver);
values.put("priority", priority);
values.put("reason", reason);
payload.put("values", values);
headers = Map();
headers.put("Content-Type", "application/json");
headers.put("x-cliq-webhook-secret", webhookSecret);
invokeUrl
[
url : webhookUrl
type : POST
body : payload
headers: headers
];
// The reply is delivered by the OpenClaw gateway via the Cliq bot API,
// so the handler itself returns an empty response.
response = Map();
return response;
When a form submission arrives, the plugin synthesizes the agent's message body from the submitted values:
Form: approval_request
approver: alice@corp.com
priority: High
reason: prod deploy gate
The raw structured values are ALSO surfaced on the inbound context as FormValues (a string-keyed map) and FormName (the form's display name), so an agent tool or downstream flow can read them as structured data rather than parsing the body text. A form submission is treated as a directed action at the bot — a group form submission is admitted without a separate @mention (the same way a reply to the bot is). DM admission (dmPolicy / allowFrom) and self-message / dedupe guards apply unchanged. A form submission whose every field is empty is dropped (no agent-readable content). No new OAuth scope is required — the Form Handler is a bot handler that posts to the webhook over the same x-cliq-webhook-secret-authenticated transport as Message / Mention / Welcome. There is no separate opt-in config field — if no form is wired up, no form submissions arrive.
5c. Agent-rendered forms (outbound structured input)
In addition to the inbound Form Handler above, the agent can solicit structured input at runtime by rendering a form as a native Cliq prompt card with a button per option — the portable equivalent of a Cliq platform Form, emitted on demand. The agent calls the shared message tool with a form param:
message(action=send, to="cliq:user:u-1", form={
title: "Which priority?",
fields: [
{ name: "priority", label: "Priority", type: "select",
options: [{ label: "High", value: "high" }, { label: "Low", value: "low" }] },
{ name: "reason", type: "text", placeholder: "brief justification" }
]
})
Rendering rules:
- Each
selectfield with ≥2 options becomes its ownpromptcard (a button per option, capped at 5; extras listed in the card body). Tapping a button posts a__cliq_form__ <fieldName>=<value>sentinel payload back to the bot, which the plugin recognizes and surfaces on the inbound context as a structuredFormValuesentry ({ <fieldName>: <value> }) — so an agent-posted form's answers re-enter as structured params for a tool call, not plain text. The agent envelope body is the clean<fieldName>: <value>rendering (sentinel stripped). A button click is a directed action at the bot, so a group form response is admitted without a separate @mention. text/numberfields (and optionlessselectfields) fold into a singlemodern-inlinesummary card posted before the prompt cards, listing each as a question with areply with <name>: <value>hint. These free-text replies are not sentinel-prefixed and re-enter as ordinary message text (the agent reads them as typed answers); only prompt-card button clicks are structured.- An optional
messageparam prefixes the first card's text as extra context (instructions, context, etc.). - A degenerate form (no viable fields) returns an error so the agent can correct and retry.
No new OAuth scope — prompt cards reuse the same card-path scopes the existing message(action=send, buttons=…) path uses (ZohoCliq.Webhooks.CREATE for DM cards via client_credentials; ZohoCliq.Channels.UPDATE on v2 / ZohoCliq.Channels.CREATE on v3 for channel cards via the refresh-token grant). On v2 the prompt theme is ignored and the buttons render as a standard v2 bot-message buttons array; on v3 (apiVersion: "v3") a real prompt-theme Message Card is posted. No config field — the form param is always available on a configured account.
Payload format reference
The plugin parses the JSON payload posted by the Deluge handler. The canonical shape is:
{
"handler": "mention", // "mention" | "message"
"message": { "text": "hi", "id": "msg_123", "time": "2026-07-04T10:00:00Z" },
"user": { "id": "12345", "name": "Jane Doe", "email_id": "jane@example.com" },
"chat": { "id": "cl_abc", "type": "channel", "title": "Engineering" },
"channel": { "id": "ch_1", "name": "engineering", "unique_name": "engineering" },
"mentions": [ { "id": "openclaw_agent", "name": "OpenClaw Agent", "type": "bot" } ]
}
Notes (the parser is tolerant):
messagemay be a plain string instead of{ text, id, time }.- A wrapped
paramsobject ({ params: { message, user, channel } }) is also accepted. - Group vs DM detection:
chat.type === "channel"(or the presence ofchannel.*fields) marks a group; otherwise the message is treated as a DM. - The
x-cliq-webhook-secretheader is checked against the configuredwebhookSecret. Onlyx-cliq-webhook-secretis accepted;x-webhook-secretandAuthorization: Bearer <secret>are rejected. - Form submissions (see §5b): a payload with
handler: "form"and/or a non-emptyvaluesobject (also accepted underform.values/form_data/formvalues, including inside aparamswrapper) is recognized as a Cliq Form submission; the submitted field values synthesize the agent body and are surfaced asFormValues/FormNameon the inbound context. - Agent-rendered form button clicks (see §5c): a prompt-card button posts
__cliq_form__ <fieldName>=<value>as the message text; the plugin recognizes the sentinel and surfaces the answer as aFormValuesentry ({ <fieldName>: <value> }) on the inbound context (structured params for a tool call), with the clean<fieldName>: <value>rendering as the agent body.
Inbound quote / reply context
When a user replies to or quotes a message in Cliq, the Deluge message handler receives the new message — Cliq does not automatically attach the quoted message's text to the bot. The plugin surfaces the referenced message to the agent from whatever the handler forwards:
message.reply_to(string message id) — the documented Cliq shape. The plugin carries the parent message id into the inbound context.parent/quoted/parent_message/quoted_message/reply_to_message(object at the payload root, or undermessage, including through theparamswrapper) — the full parent message{ id, text, sender: { id, name } }or a parent id. When the handler forwards this, the agent sees the quoted text + sender directly.
When only the parent id is present (e.g. the default message.reply_to shape) and a user-context refreshToken is configured, the plugin best-effort fetches the parent message text via GET /api/v2/chats/{chatId}/messages and prepends it to the agent envelope as:
↩ Replying to <senderName>:
> <quoted text>
<the user's message>
A failed or empty fetch degrades to "no quote text" and never breaks the turn.
A reply to the bot in a group is also admitted as an implicit mention (the reply_to_bot / quoted_bot gating kinds) — the user does not need to re-@mention the bot when replying to one of its messages.
Live handler note: The standard generated Deluge handler currently forwards only the proven
messagestring (not an object), so a reply's parent id reaches the plugin only if Deluge forwardsreply_to/parentor if a future Zoho handler exposes the field. Forwarding the parent object is optional. Without it the plugin still carries the parent message id whenever one is present; it only cannot show the quoted text unless arefreshTokenis configured (so the plugin can fetch it). If your Deluge handler can resolve the parent message (e.g. via the Cliq RESTGET /chats/{CHAT_ID}/messages/{MESSAGE_ID}endpoint), add it underparent(orquoted) so the agent sees the quote even in DM-only setups with norefreshToken. Never invent unproven Deluge variables in the handler script, as unsupported symbols fail permanently withexecution_handler_update_failed.
Inbound forwarded messages
When a user forwards a message into the chat, the forwarded content is not part of the plain message string the bot Message handler receives (the handler delivers message as a string — see learning 103). The parser therefore recognizes the original under any of these keys, at the payload root and under message (also through the params wrapper):
forwarded_message/forwardedMessage/forwarded/forward/forwarded_content/original_message/originalMessage(the original body as a string or as an object withtext/content.text/content.comment,sender.{id,name,first_name,last_name},time,id,chat.title)is_forwarded: true/isForwarded: true(boolean marker)
Behavior:
- A forward with no caption of its own promotes the original text to the turn body, so the agent has something to act on.
- The dispatch path prepends an attribution block so the agent can tell a forward apart from the user's own words:
⤷ Forwarded message from <senderName> (<time>):
> <original text>
<the user's caption, if any>
- A recognized forward that carried no readable text at all (marker present, nothing usable) still dispatches as
<forwarded message>instead of being dropped. - A payload the parser cannot use at all is logged before the 400:
[cliq] inbound rejected: <reason>; top-level keys: <names>— key names only, never values. If Zoho changes or adds a forward shape, the log names the exact keys to support next.
Stop / abort the running turn
A user can interrupt a running agent turn by sending a stop intent — stop, /stop, esc, or a common localized equivalent (halt, arrête, 停止, стоп, …). When the plugin recognizes the intent it marks the turn as an authorized command, and the OpenClaw runtime's fast-abort path cancels the in-flight run for that session (cancelSession + run-target abort), clears any queued follow-ups, stops spawned sub-agents, and replies with the canonical acknowledgement (⚙️ Agent was aborted.) in the same chat — instead of queueing another agent turn behind the one still running. No extra config, scope, or Deluge wiring is required; the trigger set is the shared one every OpenClaw channel uses.
- In a DM, any stop intent aborts the running turn (DMs are always directed at the bot).
- In a channel, the user must
@mentionthe bot (@bot stop) so the abort is admitted under the same mention gate as a normal reply — a barestopin the channel is treated as room chatter and ignored.
Verification
After the steps above, send a test message:
- DM the bot in Cliq (if
dmPolicyisallowlist, make sure your Zoho user id is inallowFrom). - @mention the bot in a channel it was invited to.
Both should trigger a POST /cliq/webhook on your gateway (visible in the gateway logs) and an agent reply in the same chat. If nothing arrives, check:
- The bot is active/published in Cliq.
- The Deluge handler is saved and the webhook URL / secret are correct.
- The gateway host is reachable from the public internet (curl
https://<gateway-host>/cliq/webhookfrom an external host — a405 Method Not Allowedon GET means the route is live). - The OAuth client has all the scopes from step 3b (including
ZohoCliq.Channels.UPDATEfor channel replies), andoauthBase/apiBasematch your data center (the plugin defaults to EU — see Data centers). For channel @mention replies and message edits,refreshTokenfrom step 3c must be set — otherwise those paths fail withoauthtoken_scope_invalid.
Agent-assisted onboarding with Zoho MCP
A Zoho Cliq MCP connection lets an agent inspect and test most of the Cliq-side setup instead of asking an operator to click through every screen. Use a customer-specific MCP connection, never a connection from another Zoho organization.
Recommended read/test actions:
list bots, get bot, get bot handler, list bot handler executions,
list bot subscribers v2, list channels, get channel, list channel members,
list chats, get chat, list chat members, list messages, get message,
searchMessagesV3, list users, get user, list functions v2,
get function v2, get function handler v2, send message to bot,
send bot message v2, postMessageInChannelByChannelUniqueName
If the agent is also allowed to repair the setup, add add bot to channel, create bot handler, update bot, update bot handler, create function v2, create function handler v2, update function v2, and update function handler v2. Do not enable delete actions for routine onboarding.
The agent can then verify that the bot is active and published, inspect Message/Mention handler metadata and execution errors, confirm channel membership, and run DM/channel end-to-end tests. The MCP connection does not expose or export the OAuth clientId, clientSecret, refreshToken, or the shared webhookSecret; those values still have to be supplied securely to OpenClaw once. Some Zoho MCP wrappers may reject get bot handler or handler-execution requests even though the underlying Zoho Cliq REST API supports them. In that case, inspect the handler in the Cliq UI or call the REST API directly; do not overwrite an unreadable handler blindly.
Cliq doctor
openclaw cliq doctor is the staged diagnostic for a configured Cliq account. Default mode is read-only: it never sends a message, never updates a handler, never writes config, and never restarts a service. It reuses the existing doctor warnings, OAuth capability matrix, public webhook preflight, and directory listing instead of reimplementing those checks. When channels.cliq.publicWebhookUrl is missing, the public-webhook stage still inspects both Zoho handlers. If they agree on one valid HTTPS /cliq/webhook URL and their secret fingerprints match the configured webhookSecret, the report names that candidate and the --adopt-handler-url repair; it does not guess among differing, missing, or unparseable URLs. --adopt-handler-url is the explicit write path: it runs a full authenticated preflight against the candidate, then stores that URL and inboundVerifiedAt atomically. A failed or inconclusive preflight, a secret mismatch, or a write error leaves config unchanged. The doctor never updates Zoho handlers.
openclaw cliq doctor
openclaw cliq doctor --account <accountId>
openclaw cliq doctor --json
openclaw cliq doctor --adopt-handler-url
openclaw cliq doctor --outbound-test --target <user-id> --kind dm --confirm
openclaw cliq doctor --outbound-test --target <channel-unique-name> --kind group --confirm
Stages, in order:
- Config schema and secret resolution
- Runtime lifecycle/status (an OAuth-backed status probe; it cannot read the running gateway's route table — stage 6 verifies the live
/cliq/webhookroute) - OAuth
client_credentialsandrefresh_tokengrants - Required API capability probes (user lookup, channel lookup, and bot read use a safe read-only
GET. Required send scopes without such a probe —dm_send,channel_send,message_edit, and native typing — are checked against the scope set Zoho reports for the token the plugin sends with: present isgranted, absent fails the stage. A token that reports no scopes stays unverified and warns.--outbound-testremains the optional live send) - Bot existence, state, visibility, and handler inspection
- Public DNS, TLS, route, and webhook-secret enforcement
- Read-only user/channel discovery
- Optional consented outbound test (
--outbound-test)
Stage 7 is an aid for choosing a target, not a precondition for reaching one: if a directory read fails, the stage warns (the run is degraded, exit 1), target resolution is unavailable, but stage 8 still runs against an explicit --target. A failure in any earlier stage does block the consented send.
The doctor deliberately has no operator-driven inbound roundtrip. Proving inbound delivery, agent execution, and the outbound reply in one command would require a human to copy an exact challenge back through a real Cliq client while the CLI polls chat history; that protocol is interactive, text-correlated, and nondeterministic, so it produced ambiguous results instead of evidence (#267). Verify the inbound path with the authenticated non-dispatching webhook preflight, the Zoho handler execution log, and the gateway log for a real message instead.
Capability output keeps the evidence kinds visibly separate: pass means a real read-only API call succeeded; granted means Zoho reported that scope on the token the plugin sends with, which is not a send; not verified means no scope set was reported and no safe probe exists; and reported from the granted scope set, not proven means the only real probe would mutate Zoho (for example bot creation). None of the last three is a pass. Zoho can still reject a reported scope at call time with oauthtoken_scope_invalid.
Stage 5 uses the shared read-only bot inspector. It resolves the configured unique name to Zoho's internal b-… id, then reports the documented active status, visibility scope (organization, team, or personal), subscriber count, handler-secret/URL consistency, and the literal payload.put("…") keys declared by each readable Message/Mention handler. Subscriber membership is read separately and is available only to the bot creator or an organization administrator; when Zoho refuses that read, subscription remains explicitly unknown while the bot facts that were readable stay known. Missing ZohoCliq.Bots.READ, an unrecognised status/scope value, transport failure, or an incomplete subscriber walk also remains unknown and makes the stage warn/degrade — it never becomes unsubscribed by inference. A missing or inactive bot, or a known handler conflict, fails the stage. Reply/quote and forwarded-message context can reach the agent two ways: the Deluge handler forwards the fields, or channels.cliq.replyContextRecovery.enabled=true with a user-context refreshToken (ZohoCliq.Messages.READ) lets the plugin recover replied_to / forward_info from Cliq chat history. With recovery on, the stage reports recovered_from_history and can pass. Known limit: a forward without any caption reaches the webhook as an empty message, the parser rejects it (logged as parser_rejected) before recovery can run, so only replies and forwards that carry text are recovered. Without it, missing fields are an uncovered shape and a declared field is still declared_unexercised (static-only, never live proof), so the stage warns with a remediation that names replyContextRecovery, not Bots.READ. Required send scopes are read from the token response rather than from a message: a missing scope fails the stage, a reported scope is granted. A token response with no scope list leaves them unverified, and that read-only run stays degraded until --outbound-test exercises the send path.
Each stage reports pass, warn, fail, or skipped, with redacted evidence and actionable remediation. Timeouts and partial failures name the failed boundary.
openclaw setup is the guided, resumable path from a freshly installed plugin to a verified outbound delivery. It:
- Checks the installed OpenClaw package against
.github/openclaw-compat.jsonand reportssupported,unsupported, orunknown. - Asks for the Zoho data center and names the unavoidable Zoho UI actions: create a Self Client at that region's API Console, then paste the Deluge Message/Mention handlers.
- Collects OAuth credentials and stores newly entered secrets as canonical env-backed SecretRefs (
CLIQ_CLIENT_SECRET,CLIQ_WEBHOOK_SECRET,CLIQ_REFRESH_TOKEN). Existing SecretRefs and$ENVinterpolation are preserved on rerun; literals are never written intoopenclaw.json. Newly entered values are only held for the current setup process: after setup, assign those same values to the listed gateway environment variables before restarting. - Offers idempotent bot/handler provisioning. A dry-run always runs first; creating or repairing a resource needs a separate confirmation that defaults to no.
- Verifies the public HTTPS webhook, admission policy (including trusted-organization acknowledgement), and generated config against the real OpenClaw schema.
- Offers the read-only doctor, names the supported gateway restart (
systemctl --user restart openclaw-gateway.serviceor this host's equivalent), and optionally a consented first-contact DM. - Prints a machine-readable final report (
schemaVersion: 1,command: "cliq setup") covering config, OAuth, bot, handlers, lifecycle, webhook, admission, and delivery, plus the next required action when the flow is only partially complete.
Rerunning setup keeps existing credentials and custom handlers unless a change is confirmed. Declining an optional message test is reported as cancelled, not failed. Provide the listed environment variables to the gateway service after setup; the wizard does not print secret values.
Headless provisioning
The wizard needs a TTY. For SSH and agent-driven installs three commands cover the same ground without prompts.
openclaw cliq provision assembles the config and, with --handlers, inspects and provisions the Message and Mention handlers. Secrets come from the environment (CLIQ_CLIENT_ID, CLIQ_CLIENT_SECRET, CLIQ_WEBHOOK_SECRET, CLIQ_REFRESH_TOKEN), never from argv where ps and shell history would capture them. Without --yes the command stays read-only: it prints the redacted plan and exits non-zero, so automation cannot mistake a dry run for an apply. Missing inputs are reported in one aggregated error rather than one at a time.
export CLIQ_CLIENT_ID=... CLIQ_CLIENT_SECRET=... CLIQ_WEBHOOK_SECRET=...
openclaw cliq provision --bot-id aura --public-webhook-url https://host.example.com/cliq/webhook --handlers
openclaw cliq provision --bot-id aura --public-webhook-url https://host.example.com/cliq/webhook --handlers --yes
openclaw cliq print-handlers renders the canonical Deluge scripts for the manual console-paste path from the same builder the provisioning flow uses, so a pasted handler cannot drift from an API-provisioned one. Without --webhook-secret it emits the <webhookSecret> placeholder; it never falls back to the configured secret, because reading a credential merely to print it would put it into terminal scrollback by surprise.
openclaw cliq print-handlers --webhook-url https://host.example.com/cliq/webhook
openclaw cliq print-handlers --webhook-url https://host.example.com/cliq/webhook --json
openclaw cliq oauth-exchange converts a self-client authorization code into channels.cliq.refreshToken. The token is written atomically and never printed — there is no --print escape hatch, so output masking and scrollback cannot lose it. Prefer $OPENCLAW_CLIQ_AUTH_CODE over --code to keep the single-use code out of shell history. The command reports the granted scopes and warns when the combined profile from §3b is incomplete. --check verifies an already-configured token and writes nothing.
CLIQ_CLIENT_ID=1000.… CLIQ_CLIENT_SECRET=...… OPENCLAW_CLIQ_AUTH_CODE=1000.… openclaw cliq oauth-exchange
openclaw cliq oauth-exchange --check
--outbound-test requires --target, --kind dm|group, and --confirm. It posts exactly one clearly labeled diagnostic message that needs no reply, and proves only the outbound hop: OAuth, the send scope, bot visibility/membership, and target resolution. It never waits for an answer and never starts an agent turn.
Inbound-side shapes stay explicit manual acceptance checks, because no Zoho bot API can synthesize them: use a real Cliq client to send a normal message, a native reply/quote, and a forward, then confirm each one in the Zoho bot execution log and the gateway log. Handler field declarations remain static contract evidence (declared_unexercised), never live proof. recovered_from_history is different: it means reply/quote and forward context is configured to be read back from Cliq chat history, not that this doctor run watched a native reply or forward.
The config stage also warns when a multi-user Cliq bot still resolves session.dmScope to main (shared conversation context and delivery route) and whenever ackPolicy: "immediate" is configured (a crash after the ack loses the message; on OpenClaw >= 2026.8.2 the plugin must reserve detached work with runDetachedWebhookWork before responding or the healthy gateway rejects the turn with GatewayDrainingError).
--json emits a stable report. The documented keys are:
{
"schemaVersion": 2,
"command": "cliq doctor",
"mode": "read_only",
"accountId": "default",
"startedAt": "2026-08-27T10:00:00.000Z",
"completedAt": "2026-08-27T10:00:01.000Z",
"outcome": "healthy",
"exitCode": 0,
"readOnly": true,
"stages": [
{
"id": "config",
"label": "Config schema and secret resolution",
"status": "pass",
"evidence": ["…"],
"remediation": []
}
]
}
invocationError is present only for an invalid invocation. boundary is present on a stage that named a failed or inconclusive hop. Schema 2 removed the roundtrip stage and the optional correlation object (#267).
Exit codes:
0— healthy (every applicable stage passed)1— degraded (warnings, or a stage skipped for a missing subsystem)2— failed3— invalid invocation
Secrets, tokens, auth codes, and sensitive Zoho response bodies are redacted from every report.
The public-webhook stage includes the same optional handler_secret comparison as openclaw cliq webhook-preflight: with botId and ZohoCliq.Bots.READ, Message/Mention handler secret fingerprints and webhook URLs are checked against loaded config. A mismatch fails the stage without exposing either secret; an unreadable or unrecognisable handler is reported as skipped, never as evidence of equality.
Smoke testing with curl
You can verify the webhook route and the expected JSON body shape independently of Zoho Cliq. Replace <gateway-host> and <secret> with your values:
curl -i -X POST 'https://<gateway-host>/cliq/webhook' \
-H 'Content-Type: application/json' \
-H 'x-cliq-webhook-secret: <secret>' \
--data '{
"handler": "message",
"message": { "text": "hello from curl", "id": "smoke_1" },
"user": { "id": "smoke-user", "name": "Smoke Tester" },
"chat": { "id": "smoke-chat", "type": "channel", "title": "Smoke" }
}'
Expected response (the webhook acknowledges receipt synchronously and dispatches asynchronously):
HTTP/2 200
content-type: application/json
{"status":"received"}
200 {"status":"received"}— the route is live, the secret matched, and the body parsed as JSON. The agent reply (if any) is delivered asynchronously to the chat id you supplied.401 unauthorized— thex-cliq-webhook-secretheader did not matchwebhookSecret.400 ... is not valid JSON— the body was not JSON (e.g. you usedparameters:in Deluge, orContent-Typewasapplication/x-www-form-urlencoded). Re-check the Deluge handler in §5.503 cliq not configured— the channel account is not configured inopenclaw.json(see §4).
Data centers
Zoho stores each account in a single regional data center, and the API / OAuth
domain differs per region (accounts are DC-exclusive — a .eu account cannot
authenticate against .com). The plugin defaults to the EU endpoints; for any
other region, set oauthBase and apiBase in the config (§4)
to the values below, and use the matching domain for the API Console and token
URLs throughout the setup guide.
| Region | Domain | oauthBase | apiBase |
|---|---|---|---|
| Europe (plugin default) | .eu | https://accounts.zoho.eu | https://cliq.zoho.eu |
| United States | .com | https://accounts.zoho.com | https://cliq.zoho.com |
| India | .in | https://accounts.zoho.in | https://cliq.zoho.in |
| Australia | .com.au | https://accounts.zoho.com.au | https://cliq.zoho.com.au |
| Japan | .jp | https://accounts.zoho.jp | https://cliq.zoho.jp |
| Canada | zohocloud.ca | https://accounts.zohocloud.ca | https://cliq.zohocloud.ca |
| Saudi Arabia | .sa | https://accounts.zoho.sa | https://cliq.zoho.sa |
| China | .com.cn | https://accounts.zoho.com.cn | https://cliq.zoho.com.cn |
Which region am I on? Check the domain you log into Zoho at (e.g. cliq.zoho.in
→ India), or read the api_domain / location value Zoho returns during the
OAuth flow.
Auto-detection. You do not have to get the region right on the first try:
- The setup wizard (
openclaw configure) prompts for your Zoho data center first and writesoauthBase+apiBasetogether from the region table above (EU is the default; a re-run reuses your existing region so a non-EU account is never silently reset to EU). The printed API Console URL matches the region you pick. - After the first successful OAuth token exchange, the plugin reads the
api_domainfield Zoho returns in the token response and, when it indicates a region that disagrees with the configuredapiBase, self-correctsapiBaseto the matchingcliq.zoho.<tld>(the rawzohoapishost is mapped back to the Cliq host — never used directly) and logs one warning.oauthBaseis left unchanged: a wrongoauthBasefails before anyapi_domainis returned, so it cannot self-heal — set it via the wizard or the config table above. openclaw doctorwarns when only one ofoauthBase/apiBaseis set (the other defaults to EU, splitting OAuth + REST across regions) or when the two point at different regions (a likely copy-paste mistake).- A Zoho auth failure (
invalid_client/oauthtoken_scope_invalid/ 4xx auth) surfaces averify your Zoho data centerhint pointing back here. This includes v3 endpoints (apiVersion: "v3"), whose{"message":"…"}error envelope is parsed so the auth-failure patterns match the extracted message text (e.g. a v3 401 "…invalid AuthToken." triggers the same hint).
Example — a US-based account (.com). EU accounts can omit both fields:
{
"channels": {
"cliq": {
"clientId": "<from the .com API Console>",
"clientSecret": "<from the .com API Console>",
"botId": "openclaw_agent",
"oauthBase": "https://accounts.zoho.com",
"apiBase": "https://cliq.zoho.com"
}
}
}
Install from a local checkout
ClawHub is the published path. A local checkout is how this plugin is developed and rolled out today: clone, npm ci, typecheck, test, build, then link the working tree into the gateway.
git clone https://github.com/sprintberlin/openclaw-cliq.git
cd openclaw-cliq
npm ci
npx tsc --noEmit && npm test && npm run build
Then install. On the supported runtime (OpenClaw 2026.8.2 and later) a local path is outside ClawHub review and trust metadata, so a non-interactive install is cancelled unless you pass --force. That flag is the documented acknowledgement of the warning, not a way to skip a real safety check.
openclaw plugins install --link --force --accept-capabilities ~/github_repos/openclaw-cliq
Older OpenClaw runtimes are no longer supported or tested by this plugin; on those versions --force meant "overwrite an existing install" and was rejected together with --link.
Expected output includes:
Plugin manifest id "cliq" differs from npm package name "@sprintcx/openclaw-cliq"; using manifest id as the config key.
Linked plugin path: ~/github_repos/openclaw-cliq
Restart the gateway to load plugins.
The manifest-id line is expected and correct: the config key is cliq (channels.cliq, plugins.entries.cliq), not the npm package name. Do not rename the package or the manifest to silence it.
--link keeps the installation pointed at this working tree, so every later git pull needs a rebuild plus a gateway restart or the running gateway keeps serving the previous dist/:
git pull --ff-only
npm ci && npx tsc --noEmit && npm test && npm run build
systemctl --user restart openclaw-gateway.service # or however this host starts the gateway
Contributing
Bug reports, feature requests, and pull requests are welcome — see CONTRIBUTING.md for local development, conventions, and the PR flow, and SECURITY.md for private vulnerability reporting. Release and ClawHub-publish steps live in RELEASING.md; the version history is in CHANGELOG.md.
Development
This plugin is developed iteratively by the maintainers and contributors. See AGENTS.md for project context and conventions, and ROADMAP.md for the open worklist / feature-parity target. Changes are reviewed through the normal pull-request workflow.
License
MIT