@sprintcx

Zoho Cliq

Native Zoho Cliq channel for OpenClaw — reply to DMs and channel @mentions as a bot, with streaming previews, cards, and message actions.

当前版本
v0.6.0
code-plugin社区source-linked
<p align="center"> <img src="https://raw.githubusercontent.com/sprintberlin/openclaw-cliq/main/assets/zoho-cliq-128.png" alt="Zoho Cliq" width="96" height="96"> </p> <h1 align="center">Zoho Cliq Channel for OpenClaw</h1> <p align="center"> Connect your OpenClaw agent to <a href="https://www.zoho.com/cliq/"><b>Zoho Cliq</b></a> — reply to DMs and channel @mentions as a native bot, with block-streaming live-edit, cards, buttons, and message actions. </p> <p align="center"> <code>openclaw plugins install clawhub:@sprintcx/openclaw-cliq</code> </p> <p align="center"> <b>Public open-source repository:</b> <a href="https://github.com/sprintberlin/openclaw-cliq">github.com/sprintberlin/openclaw-cliq</a> </p> <p align="center"> <b>Channel plugin</b> · OAuth 2.0 · multi-data-center · MIT · verified live on a real gateway </p>

⚡ Quick start

Get a bot answering DMs in four steps (channel @mention replies add one OAuth step — see Setup guide below):

  1. Create a Cliq bot — Zoho Cliq → Bots → Create Bot. Note the Bot Unique Name (botId, not the internal b-... bot ID) and display name.
  2. Get OAuth credentials — Zoho API Console (use your data center's domain) → Self Client → note Client ID + Client Secret.
  3. Install & configure
    openclaw plugins install clawhub:@sprintcx/openclaw-cliq
     openclaw setup            # pick "Zoho Cliq" — the guided flow writes, validates, and reports the account config
    
  4. 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 via channelsbyname). Inbound image / file / voice attachments are downloaded and handed to the agent. stop / /stop / esc interrupts 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 as FormValues / FormName; the agent can also solicit structured input by rendering a form as a native prompt card — a button click re-enters as a structured FormValues entry (parameter capture).
  • ✍️ Rich replies — Markdown → Cliq formatting, block-streaming live-edit of one message (not token-level growth — see streaming below), interactive buttons & cards, slash-style commands, reply threading. Opt-in v3 Message Cards (apiVersion: { channelCard: "v3" } — DM cards already default to v3 via dmPost) add modern-inline / prompt / poll themes, supporting-content slides (table / list / label / image / text blocks), modern-inline sections (in-card labeled field groups), and a thumbnail header image.
  • ⚡ Message actions — Edit / delete / react to sent messages from the agent.
  • 🔐 OAuth 2.0 — client_credentials for DMs; a user-context refresh token for channel posts / message edits. Works on any Zoho data center.
  • 🛡️ DM security — allowlist / pairing / open / disabled policies 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-execution eventId, so a real Cliq message.id or that eventId retains 30-minute replay protection while two deliberate identical commands (/new twice) stay independent turns. Legacy handler scripts that forward no eventId fall 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 existing Messages.READ endpoint 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 / channels health probe, openclaw directory lookup, staged openclaw 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 stays running for as long as the gateway is up, and lastInboundAt / lastOutboundAt fill in after real traffic. openclaw security audit includes Cliq-specific findings in its default sweep; --deep is only needed for live gateway probes.
  • 🧭 Bundled companion skill — installation also publishes the cliq operations 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 on sprintberlin/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 .com with 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 set oauthBase and apiBase in 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.2 minimum. The plugin is typechecked and built against 2026.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 ngrok all 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>
  1. In Bots & Tools, open the Bots section.

  2. Click Create Bot.

  3. 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 botId you 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), and channel_participation. There is no bot_unique_name or bot_type field — both are rejected as extra_key_found, and unique_name comes back in the response. A verified direct REST create with scope: "organization" can still read back as personal; 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 with input_pattern_mismatch, so use the direct REST contract or the Cliq UI rather than treating MCP as reliable provisioning.

  4. 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.
  5. Publish / Activate the bot (it must be active to receive events).

  6. 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 setup can 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 as unknown rather than guessed.

  7. 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 @mention unless groups.<channel>.requireMention is set to false. groupPolicy: "allowlist" admits only listed channels; disabled blocks 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 botId config field. The display name is botName (used for @mention stripping in the agent-visible text). Do not substitute Zoho's internal b-... bot ID: that separate ID is required by bot/handler provisioning CRUD, while runtime message paths and botId use 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.

  1. Pick a strong random secret (e.g. openssl rand -hex 32) — this becomes your webhookSecret.

  2. Note the public URL of your OpenClaw gateway, e.g. https://openclaw.example.com. The full webhook URL is:

    https://<gateway-host>/cliq/webhook
    

    The route is registered with auth: "plugin", so no additional gateway-level bearer token is required; the webhookSecret is verified by the plugin itself via the x-cliq-webhook-secret header.

  3. 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/webhook
    

    When the checked URL matches the configured channels.cliq.publicWebhookUrl, a passing run records channels.cliq.inboundVerifiedAt and a proven failing run records inboundVerificationFailedAt (clearing any stale verification) — so verifying via the CLI counts just like verifying via the wizard. The method/reachability boundary retries transient 502, 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-write keeps the command a pure read-only probe:

    openclaw cliq webhook-preflight https://<gateway-host>/cliq/webhook --no-write
    

    openclaw setup runs the same check and will not report inbound Cliq as ready when it fails. When the configured account has botId, client credentials, and the ZohoCliq.Bots.READ scope, the preflight resolves the configured bot unique name through the complete paginated bot listing to Zoho's internal b-… 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 their webhookSecret literals with the resolved config secret, plus each webhookUrl with the URL being tested. The resolution is cached for the diagnostic run. A missing or ambiguous unique-name match, incomplete listing, API failure, missing Bots.READ, no botId, 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 as openclaw-cliq-preflight/<package-version> (+https://github.com/sprintberlin/openclaw-cliq) so edge/WAF rules can allowlist it deliberately (the version is this package's package.json version); use --user-agent <value> when you need to reproduce the identity your Zoho/Deluge delivery uses. An HTTP 403 is 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, see openclaw 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 18789
    

    405 plus the plugin's own route signature means the route is registered. Anything else — including a bare 404, 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 use openclaw plugins inspect cliq --runtime --json for this — it reports "httpRoutes": 0 even 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).

  4. In the Cliq Bot's Deluge editor (see step 5 below), set the webhook URL and the secret header on every invokeUrl call.

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, scope ZohoCliq.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, scope ZohoCliq.Channels.UPDATE) and message edits (/chats/{chatId}/messages/{messageId}, scope ZohoCliq.Messages.UPDATE) → a user-context refresh token obtained once via the self-client authorization_code flow. Native v3 typing (/api/v3/chats/{chat_id}/activities, scope ZohoCliq.Chats.UPDATE) is consented on the same Self Client refresh-token string. The plugin mints short-lived access tokens from the refresh token via grant_type=refresh_token and caches them until they expire (~1h). Without a refresh token, channel replies and block-streaming live-edit will fail with oauthtoken_scope_invalid — DM-only setups keep working. client_credentials can mint a token that reports Chats.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

  1. 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.

  2. Create a Server-based Application (or Self Client) and note:

    • Client ID
    • Client Secret
  3. Your data center's OAuth token endpoint (example uses US .com):

    https://accounts.zoho.com/oauth/v2/token
    

    The plugin defaults to the EU endpoint (https://accounts.zoho.eu). If your account is on another data center, set oauthBase (and apiBase) in the config (§4) to match — see Data centers.

  4. Copy Client ID and Client Secret — they go into clientId / clientSecret in 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}/message send path).
  • ZohoCliq.Channels.UPDATE (refresh token) — Post bot messages to channels (the /channelsbyname/{unique_name}/message send path).
  • ZohoCliq.Channels.CREATE (refresh token) — Post a v3 Message Card to a channel (only when the channelCard family resolves to v3 — the v2 channel card path reuses Channels.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 via POST /api/v3/chats/{chat_id}/activities with body {"action":"typing"} (success is empty HTTP 204). A user id is not a chat id. This scope does not unlock GET /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 defaults heartbeat.typing to "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 via GET /api/v2/chats/{chatId}/messages to 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 the delete family resolves to v3 — the v2 single-message delete reuses Messages.UPDATE; opt-in, see §4).
  • ZohoCliq.messageactions.CREATE (refresh token) — Add / remove message reactions (the message(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-understanding describe 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 explicit tools.media.image provider, 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.UPDATE and ZohoCliq.Messages.UPDATE added — channel replies will be rejected with invalid_scope / 401 until you do. Tokens minted before ZohoCliq.Chats.UPDATE was required fail native v3 typing with oauthtoken_scope_invalid until you re-consent the Self Client including that scope and regenerate the refresh token. ZohoCliq.Messages.DELETE and ZohoCliq.Channels.CREATE are only needed when you opt the corresponding family into v3 (delete / channelCard — see §4); the v2 paths reuse Messages.UPDATE / Channels.UPDATE respectively, so if you keep those families on the "v2" default you can skip them. The v3 bot-DM endpoint (the dmPost default) uses the same ZohoCliq.Webhooks.CREATE scope as v2 DMs (client_credentials, no extra scope) — though some orgs may additionally require ZohoCliq.BotMessages.CREATE; if yours does, fall back with apiVersion: { dmPost: "v2" }. Reactions (ZohoCliq.messageactions.CREATE) are optional — skip the scope if you don't need the react action, and the plugin will simply not advertise reaction support. ZohoCliq.Messages.READ is 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. Likewise ZohoCliq.Attachments.READ is 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
CapabilityScopeGrantRequired
DM sendZohoCliq.Webhooks.CREATEclient_credentialsyes
Channel sendZohoCliq.Channels.UPDATErefresh_tokenyes
Message edit / streamingZohoCliq.Messages.UPDATErefresh_tokenyes
Native chat typing (v3 activities)ZohoCliq.Chats.UPDATErefresh_tokenyes
User lookupZohoCliq.Users.READclient_credentialsyes
Channel lookupZohoCliq.Channels.READclient_credentialsyes
Channel card (v3)ZohoCliq.Channels.CREATErefresh_tokenno
Message read (file-id, quote)ZohoCliq.Messages.READrefresh_tokenno
Message delete (v3)ZohoCliq.Messages.DELETErefresh_tokenno
ReactionsZohoCliq.messageactions.CREATErefresh_tokenno
Media downloadZohoCliq.Attachments.READrefresh_tokenno

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
CapabilityScopeGrantConsent tier
Bot read (inspect existing bots)ZohoCliq.Bots.READclient_credentialsinspect-only
Bot createZohoCliq.Bots.CREATEclient_credentialsprovisioning
Bot / handler updateZohoCliq.Bots.UPDATEclient_credentialsprovisioning

Inspect vs. create is a real difference. A token consented with only Bots.READ + Bots.UPDATE is issued happily (the token response even reports both scopes) but POST /api/v3/bots fails with {"code":"oauthtoken_scope_invalid"} — the error suggests a bad token when the real cause is the missing ZohoCliq.Bots.CREATE consent. 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 dmPost family defaults to v3 (see §4): bot DM posts route through POST /api/v3/bots/{botId}/messages with the ZohoCliq.Webhooks.CREATE scope obtainable via client_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 as userids + sync_message: true, so the response carries the sent message_id + chat_id (under message_details.<userId>) — what the thinking placeholder and DM live-edit-in-place need to edit the reply in place. Some orgs may additionally require ZohoCliq.BotMessages.CREATE for the v3 bot endpoint; if yours rejects the v3 DM post, restore the v2 path with apiVersion: { dmPost: "v2" }.

Channel text posts still need the refresh token by default (channelPost defaults to v2 → Channels.UPDATE). Opting apiVersion: { channelPost: "v3" } routes channel text posts through POST /api/v3/channelsbyname/{name}/messages (also Webhooks.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 endpoint DELETE /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 endpoint POST /api/v3/channels/{name}/message with the ZohoCliq.Channels.CREATE scope — a user-context scope, same constraint as Channels.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" endpoint POST /api/v3/bots/{botId}/messages with a top-level card field — the same Webhooks.CREATE scope as DM text posts (client_credentials, no refresh token needed), posting as the bot and addressing the recipient via userids (no chat-id resolution needed). If you only need DM text + DM cards, the default already skips this step entirely. Skip Messages.DELETE from the scope if you don't use deletes; likewise skip Channels.CREATE if 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):

  1. In the Zoho API Console (your data center) → your Self Client → tab Generate Code.
  2. 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
    
  3. Time Duration: 10 minutes. Scope Description: anything (e.g. openclaw). Pick your portal/org if prompted.
  4. 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_code on exchange → the code expired (>10 min) or was already used once. Generate a fresh one.
  • Native typing still oauthtoken_scope_invalid → the refreshToken was minted without ZohoCliq.Chats.UPDATE. Re-consent the Self Client with the full scope list and regenerate the refresh token.
  • Channel replies still oauthtoken_scope_invalid → the refreshToken is missing from config, or was minted without ZohoCliq.Channels.UPDATE. Re-run 3c with the full scope list.
  • channel_not_exists on 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 name invest).

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: dmPolicy and allowFrom do 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.

main is the runtime default whenever the session block is absent, so this affects fresh installations, not only upgrades from an older config. With dmScope: "main" every DM collapses into agent:<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 stricter per-account-channel-peer, which additionally isolates the same sender across accounts. Restart the gateway after changing it — it is read at startup.

openclaw cliq doctor warns when a multi-user Cliq bot still resolves to main, and openclaw security audit reports 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/webhook route and authenticates plus dispatches it with resolveCliqConfig(cfg, null) — there is no header, path, or payload multiplexing into channels.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 own botId, 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 or true keeps the channel running; false keeps the credentials in place but stops the account from starting. Named accounts under channels.cliq.accounts.<id> can override this with their own enabled. This is not the same key as plugins.entries.cliq.enabled: that OpenClaw-owned switch unloads the plugin entirely, so a channels.cliq.enabled: true cannot re-enable a plugin that was turned off there. The setup wizard writes channels.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 internal b-... 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 @botName mention from the text the agent sees.

  • ownSenderIds (optional) — Extra identities that belong to this bot, most importantly Cliq's internal b-… 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 in ownSenderIds. 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 the x-cliq-webhook-secret header. If unset or unresolved, /cliq/webhook fails closed with 503 and never dispatches an agent turn. A missing or wrong request header returns 401.

  • publicWebhookUrl (optional) — The public HTTPS URL Zoho posts to, e.g. https://cliq.example.com/cliq/webhook. Recorded by openclaw setup so it can verify inbound delivery, and used to identify whether openclaw 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 of openclaw cliq doctor. When the field is missing, the doctor can name the URL both Zoho handlers already agree on; openclaw cliq doctor --adopt-handler-url stores that verified URL and inboundVerifiedAt after 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 by openclaw setup when the preflight passes, and also by openclaw cliq webhook-preflight <url> when the checked URL is the configured publicWebhookUrl (pass --no-write for 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 configured publicWebhookUrl proves 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 and inboundVerifiedAt. 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 from GET /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-context refreshToken with ZohoCliq.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-client authorization_code flow (§3c). Required for channel @mention replies, live-edit message edits, and native v3 typing — without it, those paths fail with oauthtoken_scope_invalid (the runtime profile uses refresh-token consent for ZohoCliq.Channels.UPDATE, ZohoCliq.Messages.UPDATE, and ZohoCliq.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 owns refreshToken; there is no supported bot/sender override, and bot_unique_name is rejected. Use "all" only when that human attribution is deliberate. Ambiguous raw CT_… targets fail closed under "dm"; an inbound chat record or explicit cliq: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's invokeUrl in 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's runDetachedWebhookWork before writing the 200 (#122); without that wrapper the healthy gateway refuses the post-ack turn with GatewayDrainingError and 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 with thinking.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 when dmPolicy is allowlist or pairing). This is an admission control, not session isolation; multiple allowed senders still share context unless global session.dmScope is per-channel-peer (or per-account-channel-peer).

  • dmPolicy (optional) — DM admission policy, not conversation isolation (see the multi-user privacy warning above). Default is allowlist (deny by default). pairing starts the OpenClaw pairing approval flow for unknown senders — by default the sender gets a reply with a pairing code and the bot owner runs openclaw pairing approve cliq <code> on the CLI; set pairing.notifyOwnerTarget (see the pairing row 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 like dmSecurity are rejected).

  • groupPolicy (optional) — Group/channel admission policy. Fresh generic setup defaults to disabled; open lets the bot respond in any channel where it is @mentioned, allowlist restricts it to channel unique names listed under groups, and disabled ignores all group messages. Existing configurations without groupPolicy retain 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 supports requireMention (boolean — false lets the bot respond in that channel without an explicit @mention; that always-on path also needs a provisioned participation_handler — see learning 156), ingest (boolean), tools ({ allow, alsoAllow, deny } tool policy for that channel), and toolsBySender (per-sender tool policy overrides keyed by channel: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 exact NO_REPLY token, 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 internal b-… id in ownSenderIds and other workspace bots in selfSenderIds so 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 from allowFrom: ["*"] or dmPolicy: "open"; existing open configurations remain unchanged. acknowledged: true makes the security audit classify the deliberate wildcard/open policy as informational, but does not enforce organization membership. Cliq webhook payloads may include user.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 verified x-cliq-webhook-secret plus 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, set streaming.mode: "off".

    Modes. thinking.mode selects the acknowledgement style: "placeholder" (default — post a lightweight text placeholder, default 💭 …, configurable via thinking.text, then edit it in place into the final reply — exactly one message, no duplicate), "card" (post a v3 Message Card status indicator — a modern-inline card — instead of plain text, and transition its title through explicit phases as the turn runs: it is first posted with the "thinking" phase title thinking.thinkingText, default 💭 thinking…, then edited in place to the "generating" phase title thinking.text, default Generating…, 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). On apiVersion: "v3" the card is a real card posted via the v3 Message Card endpoints (DM via POST /api/v3/bots/{botId}/messages with scope Webhooks.CREATE, channel via POST /api/v3/channels/{name}/message with scope Channels.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.animate cycles 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 fixed thinking… label), "custom" (cycles thinking.animateFrames, a string array — needs ≥2 non-empty entries or it degrades to the static placeholder), or "off" (static placeholder). The interval is thinking.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 existing editMessage path (Messages.UPDATE, same refreshToken precondition as the placeholder itself); a failed frame edit stops the animation but never breaks the turn. With streaming.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.confirm to 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 a thinking.confirmKeywords entry (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, a prompt-theme Message Card titled thinking.confirmText (default ⚠️ Confirm action?) with thinking.confirmLabel / thinking.cancelLabel buttons (defaults Confirm / 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 with thinking.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 refreshToken is 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 Core NO_REPLY silent 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.mode defaults to "partial" when unset. Existing legacy streaming.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, refreshToken present — 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:

    SwitchLayerWhat it does
    agents.defaults.blockStreamingDefaultOpenClaw block streamingFleet/global Core default. Affects channels or turns that do not set a per-turn override.
    channels.cliq.streaming.modeCliq one-message live-editSelects "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, and onPartialReply is not used for that mode.
    thinking.animatethinking placeholder animationCycles 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, OpenClaw 2026.8.1-beta.3, refreshToken present — 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:

    ModeWhat drives the visible draftWhat 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, Core onToolStart / onItemEvent / onPlanUpdate / onApprovalEvent / onCommandOutput / onPatchSummary / onNarrationUpdate callbacks drive one draft, and answer snapshots cannot overwrite that draft before final delivery. Reasoning progress is forwarded only when the existing OpenClaw reasoningDefault / session reasoningLevel is "stream". The plugin reads these settings with OpenClaw's shared streaming helpers instead of maintaining a second parser or defaults. Legacy streaming.preview: "on" | "off" remains loadable and maps to "partial" | "off"; an explicit streaming.mode wins if both keys are present. Run openclaw doctor --fix to rewrite legacy root and per-account config safely.

    What progress renders. Core owns the status headline, plan checklist, sanitized rolling lines, line identity/order, and maxLines / maxLineChars truncation. Cliq converts that composed Markdown once with markdownToCliq, 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 by streaming.minEditIntervalMs, while Core's flush requests 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, and thinking.mode: "card" because those surfaces cannot be converted into editable text in place. This requires a refreshToken with ZohoCliq.Messages.UPDATE.

    Guarantee. Same message plus in-place final edit when message ids and ZohoCliq.Messages.UPDATE are 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):

    ModelShape
    sprintcx/tier-1 + thinking=mediumsend textLen=4 → (46 s, no intermediate PUT) → one edit textLen=4097. This path can stamp openclawDelivery.textPhaseRequiresTerminal and return before emitAssistantStreamData (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 + thinking is therefore not a universal snapshot block. Plugin unit tests that call onPartialReply themselves do not prove the runtime emits it. Coalesced block deliver() 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 why heartbeat.typing defaults 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 with thinking.mode: "off" if you do not want the thinking bubble at all. The legacy streaming.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 needs refreshToken (ZohoCliq.Messages.UPDATE); without it, previews degrade to one normal final response and openclaw cliq doctor says 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 v2 PUT /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) and welcome.enabled === true, the bot posts a configurable greeting DM to the subscriber. welcome.text is used for first-time subscribers and welcome.textRejoin for users who unsubscribed and came back; both default to a friendly greeting and support {{firstName}} / {{lastName}} / {{name}} / {{id}} / {{email}} placeholders resolved from the forwarded user object. The DM admission policy (dmPolicy / allowFrom) is honored — a denied sender is never greeted, and under the pairing policy an un-paired subscriber is skipped (the pairing flow owns their first contact). Default enabled: 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 when dmPolicy is pairing). By default an unknown sender's pairing code must be approved on the CLI (openclaw pairing approve cliq <code>). Set pairing.notifyOwnerTarget to 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 (a prompt-theme Message Card with Approve/Deny invoke.bot buttons) 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 reply deniedOwnerText (the pending request is left in place; the sender is re-challenged idempotently if they message again). Only the exact DM identity configured by pairing.notifyOwnerTarget may 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. Requires botId (the v3 invoke.bot button renderer drops buttons without one); when absent or notifyOwnerTarget is 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, default Approve / 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.CREATE for DM cards via client_credentials; Channels.UPDATE on v2 / Channels.CREATE on v3 for channel cards) the existing message(action=send, buttons=…) path uses.

  • oauthBase (optional) — OAuth base URL for your Zoho data center. Defaults to the EU endpoint https://accounts.zoho.eu. Set it (together with apiBase) 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 endpoint https://cliq.zoho.eu. Set it (together with oauthBase) 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 apiVersion entirely yields these defaults — you no longer need to set apiVersion: "v3" for the DM placeholder / live-edit to work (a previous manifest default: "v2" bug silently forced all families to v2; fixed in issue #86).

    • dmPost → "v3" (the win). The v3 "Send a bot message" endpoint POST /api/v3/bots/{botId}/messages (scope ZohoCliq.Webhooks.CREATE via client_credentials — no refresh token needed, posts as the bot) sends the recipient key as userids (v2-style, NO underscore — a user_ids key is rejected with extra_key_found) and sync_message: true, so the response carries the sent message_id + chat_id under message_details.<userId> (URL-encoded — decoded once and the v2 edit URL re-encodes exactly once). That id is what the thinking.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 the ZohoCliq.BotMessages.CREATE scope for the v3 bot endpoint — if your org rejects the v3 DM post, restore the v2 path with apiVersion: { 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 endpoint POST /api/v3/channels/{name}/message (note: channels, not channelsbyname, singular message; scope ZohoCliq.Channels.CREATE, refresh-token grant) has no bot_unique_name query 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 a modern-inline Message Card body whose theme is selected by the card sender (modern-inline for agent-emitted presentations and the message(action=send, buttons=[...]) tool; the message(action=send, slides=[...]) tool attaches structured table / list / label / images / text supporting-content blocks via the theme-independent top-level slides array; the message(action=send, thumbnail="https://…", sections=[{ title?, fields: [{ title, value }] }]) tool attaches a modern-inline-only header thumbnail image (HTTPS URL) and in-card labeled field sections (both ignored for prompt / poll); prompt for the slash-command quick-reply buttons emitted by the /models and /model menus — a focused quick-reply card with a title + 1–5 action buttons, no sections; poll for voting cards emitted by the message(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 same POST /api/v3/bots/{botId}/messages endpoint as v3 DM text posts, with a top-level card field — same Webhooks.CREATE scope (client_credentials, no refresh token), posting as the bot and addressing the recipient via userids; the poll theme works for DM cards too.
    • delete → "v2". The v3 bulk-delete endpoint DELETE /api/v3/chats/{chatId}/messagess?message_ids=<id> (a 1-element delete-multiple call; scope ZohoCliq.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-message message.delete_result list 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 of apiVersion — 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 with sync_message: true do return the message id. Per-account overrides are supported, so one account can pilot a different family mix while others stay on v2.

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 setup can do this for you. When publicWebhookUrl is 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 with 401. 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 grant ZohoCliq.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_handler tools fail with 400 request_url_invalid regardless of bot-id format, while bot-level tools on the same server work. Use openclaw setup, the manual paste below, or the direct REST endpoints in the provisioning API contract.

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.

<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>
// === 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 — handler value "message"). The Mention Handler (channel/group @mentions) is the same script minus the attachment-name and multipart branches and with the handler value "mention" — i.e. use the simple raw-JSON invokeUrl branch and set payload.put("handler", "mention"). Group vs DM is detected automatically from the forwarded chat object, so no extra mapping is needed. The scripts cannot be byte-identical: the Mention Handler does not provide the attachments parameter, 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 only payload.put("attachments", attachments) still lets the plugin try the Messages.READ history lookup, but it is not sufficient for all voice-message deliveries: serialization can preserve only the displayed .wav name. Re-run openclaw setup or openclaw cliq provision after upgrading; the read-only planner detects the missing multipart branch and offers a confirmation-gated Message Handler repair. In live Cliq Message Handlers, message may be a bare string with no message.id or message.time. The eventId line above is what gives the plugin a retry-stable identity for that shape. Message identity resolves in this order: Cliq's native message.id when present, then the forwarded eventId (surfaced as MessageSid and given full 30-minute replay protection), then — only for older handler scripts that forward no eventId — a content-derived syn: hash whose dedupe identity expires after 60 seconds. Some handler configurations wrap the execution payload in params; the plugin still reads eventId from that wrapper. Re-run openclaw setup after upgrading so its read-only handler plan detects a missing eventId as a stale script and offers a confirmation-gated repair. openclaw cliq doctor also fails a matching handler that still omits payload.put("eventId") or response.put("eventId") (issue #231 — without the echo every Zoho execution row stays output: "{}"). 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/webhook and 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.webhookSecret per 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.READ consent leaves, or when a handler script or provisioning response may have been captured. Rotation means updating both the Deluge script and channels.cliq.webhookSecret.
  • Bots.READ is 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_secret preflight stage reads Message/Mention scripts with ZohoCliq.Bots.READ and 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's parameters: key serializes the value as form-urlencoded data (handler=mention&message=...), which is not the JSON body this plugin expects — the gateway returns 400 Unexpected token 'h', "handler=me"... is not valid JSON. Always use body: together with the Content-Type: application/json header shown above. Even on that canonical path, Zoho's Map.toString() does not reliably escape quotes or newlines in message and 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 select field with ≥2 options becomes its own prompt card (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 structured FormValues entry ({ <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 / number fields (and optionless select fields) fold into a single modern-inline summary card posted before the prompt cards, listing each as a question with a reply 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 message param 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):

  • message may be a plain string instead of { text, id, time }.
  • A wrapped params object ({ params: { message, user, channel } }) is also accepted.
  • Group vs DM detection: chat.type === "channel" (or the presence of channel.* fields) marks a group; otherwise the message is treated as a DM.
  • The x-cliq-webhook-secret header is checked against the configured webhookSecret. Only x-cliq-webhook-secret is accepted; x-webhook-secret and Authorization: Bearer <secret> are rejected.
  • Form submissions (see §5b): a payload with handler: "form" and/or a non-empty values object (also accepted under form.values / form_data / formvalues, including inside a params wrapper) is recognized as a Cliq Form submission; the submitted field values synthesize the agent body and are surfaced as FormValues / FormName on 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 a FormValues entry ({ <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 under message, including through the params wrapper) — 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 message string (not an object), so a reply's parent id reaches the plugin only if Deluge forwards reply_to / parent or 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 a refreshToken is configured (so the plugin can fetch it). If your Deluge handler can resolve the parent message (e.g. via the Cliq REST GET /chats/{CHAT_ID}/messages/{MESSAGE_ID} endpoint), add it under parent (or quoted) so the agent sees the quote even in DM-only setups with no refreshToken. Never invent unproven Deluge variables in the handler script, as unsupported symbols fail permanently with execution_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 with text / 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 @mention the bot (@bot stop) so the abort is admitted under the same mention gate as a normal reply — a bare stop in the channel is treated as room chatter and ignored.

Verification

After the steps above, send a test message:

  1. DM the bot in Cliq (if dmPolicy is allowlist, make sure your Zoho user id is in allowFrom).
  2. @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/webhook from an external host — a 405 Method Not Allowed on GET means the route is live).
  • The OAuth client has all the scopes from step 3b (including ZohoCliq.Channels.UPDATE for channel replies), and oauthBase / apiBase match your data center (the plugin defaults to EU — see Data centers). For channel @mention replies and message edits, refreshToken from step 3c must be set — otherwise those paths fail with oauthtoken_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:

  1. Config schema and secret resolution
  2. Runtime lifecycle/status (an OAuth-backed status probe; it cannot read the running gateway's route table — stage 6 verifies the live /cliq/webhook route)
  3. OAuth client_credentials and refresh_token grants
  4. 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 is granted, absent fails the stage. A token that reports no scopes stays unverified and warns. --outbound-test remains the optional live send)
  5. Bot existence, state, visibility, and handler inspection
  6. Public DNS, TLS, route, and webhook-secret enforcement
  7. Read-only user/channel discovery
  8. 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:

  1. Checks the installed OpenClaw package against .github/openclaw-compat.json and reports supported, unsupported, or unknown.
  2. 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.
  3. 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 $ENV interpolation are preserved on rerun; literals are never written into openclaw.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.
  4. Offers idempotent bot/handler provisioning. A dry-run always runs first; creating or repairing a resource needs a separate confirmation that defaults to no.
  5. Verifies the public HTTPS webhook, admission policy (including trusted-organization acknowledgement), and generated config against the real OpenClaw schema.
  6. Offers the read-only doctor, names the supported gateway restart (systemctl --user restart openclaw-gateway.service or this host's equivalent), and optionally a consented first-contact DM.
  7. 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 — failed
  • 3 — 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 — the x-cliq-webhook-secret header did not match webhookSecret.
  • 400 ... is not valid JSON — the body was not JSON (e.g. you used parameters: in Deluge, or Content-Type was application/x-www-form-urlencoded). Re-check the Deluge handler in §5.
  • 503 cliq not configured — the channel account is not configured in openclaw.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.

RegionDomainoauthBaseapiBase
Europe (plugin default).euhttps://accounts.zoho.euhttps://cliq.zoho.eu
United States.comhttps://accounts.zoho.comhttps://cliq.zoho.com
India.inhttps://accounts.zoho.inhttps://cliq.zoho.in
Australia.com.auhttps://accounts.zoho.com.auhttps://cliq.zoho.com.au
Japan.jphttps://accounts.zoho.jphttps://cliq.zoho.jp
Canadazohocloud.cahttps://accounts.zohocloud.cahttps://cliq.zohocloud.ca
Saudi Arabia.sahttps://accounts.zoho.sahttps://cliq.zoho.sa
China.com.cnhttps://accounts.zoho.com.cnhttps://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 writes oauthBase + apiBase together 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_domain field Zoho returns in the token response and, when it indicates a region that disagrees with the configured apiBase, self-corrects apiBase to the matching cliq.zoho.<tld> (the raw zohoapis host is mapped back to the Cliq host — never used directly) and logs one warning. oauthBase is left unchanged: a wrong oauthBase fails before any api_domain is returned, so it cannot self-heal — set it via the wizard or the config table above.
  • openclaw doctor warns when only one of oauthBase / apiBase is 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 a verify your Zoho data center hint 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

源码与版本

源码仓库

sprintberlin/openclaw-cliq

打开仓库

源码提交

eff02350f12c47dc207ffb2f1303c0b88c65c9e1

查看提交

安装命令

openclaw plugins install clawhub:@sprintcx/openclaw-cliq

元数据

  • 包名: @sprintcx/openclaw-cliq
  • 创建时间: 2026/07/06
  • 更新时间: 2026/10/16
  • 执行代码: 否
  • 源码标签: v0.6.0

兼容性

  • 构建于 OpenClaw: 2026.8.2
  • 插件 API 范围: >=2026.8.2
  • 标签: latest
  • 文件数: 402