Channels
Zoho
Third-party OpenClaw channel plugin for Zoho Mail + Calendar. Published as @skavinski/openclaw-zoho on npm.
Install
npm install -g
README
# @skavinski/openclaw-zoho
Third-party OpenClaw channel plugin exposing Zoho Mail and Zoho Calendar under a single OAuth account.
## Status
Unofficial, community-maintained plugin. Not affiliated with, endorsed, or certified by Zoho Corporation. MIT licensed. Provided AS-IS, without warranty of any kind (see LICENSE for full terms). APIs and configuration shape may change before 1.0.0.
## Install
```
npm install -g @skavinski/openclaw-zoho
```
OpenClaw discovers this plugin through the shipped `openclaw.plugin.json` manifest and the `openclaw` section of `package.json`, which together declare:
- the channel id (`zoho`),
- the compiled entry point (`./dist/index.js`), and
- the setup-wizard entry point (`./dist/setup-entry.js`).
After install, the host registers the plugin through `defineChannelPluginEntry` from `openclaw/plugin-sdk/core` (see `src/index.ts:1`). This is a *third-party* entry point, not the in-repo `defineBundledChannelEntry` used for bundled channels.
## BYO Zoho OAuth credentials
This plugin operates no hosted service. Every user registers their own Zoho OAuth application and supplies the credentials locally.
1. Sign in at <https://api-console.zoho.com/> with the account that will own the integration. The API Console region (US, EU, IN, AU, JP, CA) must match the region where the Zoho account is hosted. Six regions are supported — see `src/config/regions.ts:7`.
2. Create a **Server-based Application**:
- Client Name: free-form.
- Homepage URL: `http://localhost` is fine.
- Authorized Redirect URI: leave empty if you plan to use the loopback flow (the plugin chooses the port at pairing time). If you want to pre-register a URI for the paste-back flow, register the exact URL you will paste.
3. After creation, capture **Client ID** (looks like `1000.XXXXXXXXXXXX`) and **Client Secret**. The plugin never stores the secret in plain config; it reads it through the OpenClaw secret adapter as `secret://zoho/<account>/client-secret`.
### Region endpoints
The plugin uses the OAuth, Mail, and Calendar hostnames that match the configured `dataCenter`. The mapping is defined in `src/config/regions.ts:11`:
| dataCenter | Accounts host | Mail host | Calendar host |
|------------|----------------------------|------------------------|----------------------------|
| `us` | `accounts.zoho.com` | `mail.zoho.com` | `calendar.zoho.com` |
| `eu` | `accounts.zoho.eu` | `mail.zoho.eu` | `calendar.zoho.eu` |
| `in` | `accounts.zoho.in` | `mail.zoho.in` | `calendar.zoho.in` |
| `au` | `accounts.zoho.com.au` | `mail.zoho.com.au` | `calendar.zoho.com.au` |
| `jp` | `accounts.zoho.jp` | `mail.zoho.jp` | `calendar.zoho.jp` |
| `ca` | `accounts.zohocloud.ca` | `mail.zohocloud.ca` | `calendar.zohocloud.ca` |
Pairing a mismatched region surfaces as `RegionMismatch`. SA and UK data centers are not currently supported.
### Redirect URI
The loopback mode uses `http://127.0.0.1:<ephemeral>/oauth/zoho/callback`. The port is selected at pairing time (`src/auth/loopback-server.ts:99`, `listen({ host, port: 0 })`) and the listener is single-shot with a 5-minute hard timeout.
## Pairing flow
Two pairing paths are shipped:
### In-chat pairing via `zoho.connect` (primary, mobile-compatible)
The `zoho.connect` agentTool walks the user through the full BYO-OAuth dance over the paired chat channel (e.g., Telegram). Say "connect my Zoho" — the LLM invokes the tool, and the plugin asks a fixed sequence of questions through the host's interactive reply surface:
1. What label should I use? (e.g. `work`, `personal`)
2. Paste your Zoho Client ID. (Format: `1000.XXXXXXXXXXXX`.)
3. Now paste your Client Secret. It's encrypted at rest and redacted in all logs.
4. Which Zoho data center? (`US`, `EU`, `IN`, `AU`, `JP`, or `CA`.)
5. Authorize at: `https://accounts.zoho.<tld>/oauth/v2/auth?...` — after authorizing, Zoho redirects to `https://localhost/zoho-callback` (which 404s). Paste the full URL from your browser bar, or just the `code=...` segment.
Reply `cancel` at any step to abort cleanly. The plugin validates each step (regex for Client ID, region allowlist, non-empty secret, parseable redirect paste) and retries up to 3 times per step before aborting. Timeout per prompt defaults to 10 minutes.
PKCE S256 is mandatory. State tokens are verified in constant time (`src/auth/state.ts:36`). On success the refresh token, access token, client secret, and scope-separator preference land in the secret adapter; `zoho.connect` returns a confirmation message and the regular mail/calendar tools become usable.
**Process restart aborts any in-progress pairing.** State lives only in memory (`src/util/pairing-state.ts`). If the host restarts mid-pairing, ask the user to start over with "connect my Zoho".
Example transcript (abridged):
```
user > connect my Zoho
bot > What label should I use? (e.g. 'work', 'personal')
user > personal
bot > Paste your Zoho Client ID...
user > 1000.ABCDEFGHIJ
bot > Now paste your Client Secret...
user > [redacted]
bot > Which Zoho data center? Reply: US, EU, IN, AU, JP, or CA.
user > US
bot > Authorize at: https://accounts.zoho.com/oauth/v2/auth?client_id=...
After authorizing, paste the full URL from your browser bar.
user > https://localhost/zoho-callback?code=...&state=...
bot > Connected Zoho account 'personal' in region 'US'. You can now
ask me to check mail or calendar.
```
### Desktop loopback pairing (legacy, still shipped)
For desktop use the plugin also retains the original loopback flow:
1. The plugin generates a PKCE S256 verifier/challenge pair (mandatory, no fallback to plain).
2. It starts a single-shot loopback server on `127.0.0.1` at an ephemeral port (`src/auth/loopback-server.ts:34`) and opens the Zoho authorization URL in the user's browser.
3. Zoho redirects back to the loopback with `code` and `state`. The plugin verifies state, exchanges the code for access + refresh tokens (`src/auth/oauth-client.ts:73`), and stores them via the secure token store.
4. If the loopback path is unavailable (e.g., firewall, port blocked, headless host), pairing falls back to manual paste-back of the redirect URL.
5. The listener closes after 5 minutes or after a single successful callback, whichever comes first.
**RT-003 scope-format auto-detection (in-chat pairing only).** Zoho's docs have been inconsistent about whether OAuth scopes are comma-separated or space-separated. `zoho.connect` auto-detects: if the first token exchange fails with a scope-related error, the plugin rebuilds the authorize URL with the flipped separator and re-prompts the user for a fresh code. Whichever separator succeeds is persisted to the secret adapter alongside the account's other credentials.
## Scopes
The plugin does not hard-code Zoho scope literals — the Zod schema (`src/config/schema.ts:29`) accepts a `scopes.mail` and `scopes.calendar` array supplied by config. The recommended minimal set is:
| Scope | Used for |
|---------------------------------------|------------------------------------------------------|
| `ZohoMail.messages.READ` | `zoho.mail.list`, `zoho.mail.get` |
| `ZohoMail.messages.CREATE` | `zoho.mail.send` |
| `ZohoMail.accounts.READ` | Account discovery |
| `ZohoCalendar.event.READ` | `zoho.calendar.event.list`, `zoho.calendar.event.get` |
| `ZohoCalendar.event.CREATE` | `zoho.calendar.event.create` |
| `ZohoCalendar.event.UPDATE` | `zoho.calendar.event.update` |
| `ZohoCalendar.event.DELETE` | `zoho.calendar.event.delete` |
Do not use the `.ALL` variants — they grant far more than this plugin needs.
## Rate limits
| Limit | Value | Scope |
|------------------------|-------------|------------------------------|
| Mail send (per minute) | 10 | Per account |
| Mail send (per day) | 100 | Per account |
| Overall API (per day) | 180 | Per account (configurable) |
The overall budget defaults to 180/day because Zoho's free-tier ceiling is 200/day for most plans; leaving 20/day of headroom avoids tripping the server-side quota during normal use. When a limit is hit, the plugin returns `RateLimited` with a `retryAfterMs` hint. Read and write operations share the overall daily bucket; mail send additionally consumes from the per-minute and per-day mail buckets.
See `src/util/rate-limit.ts:58` for the bucket definitions.
> **Known limitation (RT-007 / Z-18): rate-limit state is process-local.** Token buckets live in the `ZohoRuntimeBindings` for the lifetime of the current process. A host restart, or `stopAccount` → `startAccount` on this plugin, creates fresh buckets and effectively resets the budgets. This means a misbehaving agent can reset its own quota by cycling the plugin; the 200/day Zoho ceiling still applies at the upstream API. Persisting state to disk is planned for v0.2 (tracked as Z-18).
## Confirmation UX
When `requirePerSendConfirmation` is true (the default), every write operation — `zoho.mail.send`, `zoho.calendar.event.create`, `zoho.calendar.event.update`, `zoho.calendar.event.delete` — is gated behind an explicit confirmation prompt delivered through the paired channel (e.g., Telegram). The user is shown a short summary of the action and must reply `y` or `yes` for the plugin to call Zoho. Any other response, or no response within the dispatcher's timeout, aborts the action.
The confirmation contract is defined in `src/util/conf
... (truncated)
channels
Comments
Sign in to leave a comment