npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@skavinski/openclaw-zoho

v0.1.7

Published

Third-party OpenClaw channel plugin for Zoho Mail and Calendar.

Downloads

958

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 stopAccountstartAccount 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.mail.delete, 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/confirm.ts:28. Read operations (list, get) do not request confirmation.

⚠️ Host integration gotcha — three overrides are required

openclaw/plugin-sdk does not currently export a stable type for ctx.channelRuntime.reply (tracked upstream as OC-ZH-013) or a gateway-bound caller principal (OC-ZH-012). Until those land, the plugin ships safe-deny defaults for three injection points and the host MUST supply all three when constructing the gateway adapter:

  1. confirmDispatcher — powers per-send write confirmation. Default: safe-deny. Without it, every write tool is rejected with Forbidden [CONFIRMATION_DECLINED], and startAccount throws up-front (RT-009) when requirePerSendConfirmation=true.
  2. interactiveDispatcher — round-trip prompt surface used by the zoho.connect in-chat pairing tool. Default: safe-deny (cancels pairing immediately). Without it, zoho.connect returns an error explaining the host hasn't wired the surface.
  3. principalProvider — resolves the caller principal at tool-execute time (RT-005). Default: undefined, which makes authz fail closed (Forbidden [MISSING_PRINCIPAL]) whenever allowedSenders is non-empty.

Minimal host wiring:

import { buildZohoGatewayAdapter } from "@skavinski/openclaw-zoho";

const zohoGatewayAdapter = buildZohoGatewayAdapter({
  secretAdapter: mySecretAdapter,

  // 1. Confirm writes through the paired channel.
  confirmDispatcher: async (question) => {
    const accepted = await askPairedUser(question.summary); // y/yes/no
    return { accepted };
  },

  // 2. Round-trip text prompts for in-chat pairing.
  interactiveDispatcher: async ({ prompt, timeoutMs }) => {
    const reply = await awaitPairedReply(prompt, { timeoutMs });
    if (reply.kind === "text") return { kind: "reply", text: reply.text };
    if (reply.kind === "cancel") return { kind: "cancelled" };
    return { kind: "timeout" };
  },

  // 3. Resolve caller principal for the current tool call.
  principalProvider: () => currentContext.getPairedSenderPrincipal()
});

Without these injections, read tools (list, get) still work if allowedSenders is empty; write tools are denied; zoho.connect refuses to run.

Security notes

  • Tokens. Client secrets and refresh tokens are stored through the OpenClaw secret adapter (secret://zoho/<account>/<key>). Access tokens live in the per-account TokenStore and are refreshed on demand; they are never written to disk in clear form.
  • Log redaction. No log level emits partial reveals of OAuth tokens. src/util/redact.ts:42 full-masks access and refresh tokens uniformly across DEBUG/INFO/WARN/ERROR (RT-001 fix). Client IDs mask to 1000.****. Email addresses truncate at DEBUG (e***@d***.tld) and drop to [EMAIL] at INFO and above. Mail bodies and subjects are stripped by the logger before formatting (FORBIDDEN_FIELDS in src/util/redact.ts:89) and never reach disk.
  • ACL (B4). Every outbound tool invocation passes through the allowedSenders authz gate. The caller principal is plumbed via the host-supplied principalProvider (see the advisory ACL callout below) — the LLM cannot forge a principal because the principal is not part of any tool schema. Authz fails closed (FORBIDDEN / MISSING_PRINCIPAL) when no provider is wired and allowedSenders is non-empty. See src/util/authz.ts:28 and the RT-005 fix in src/tools/mail-tools.ts.
  • Untrusted content sentinels (RT-008). Every string field that originates outside the paired account (mail subject, body, addresses; event title, description, location, attendees) is wrapped in <untrusted-content source="zoho-mail|zoho-calendar">…</untrusted-content> before it is returned by a tool. The wrapper escapes < and > so an attacker cannot close the sentinel and escape the envelope. Defense-in-depth against prompt injection through mail/calendar content — see src/util/untrusted.ts.

⚠️ Security: allowedSenders is advisory until OC-ZH-012 (RT-005)

The allowedSenders ACL is advisory defense-in-depth, not a gateway-enforced authz bind. The upstream openclaw/plugin-sdk does not yet attach a tamper-proof caller principal to every outbound tool invocation (tracked upstream as OC-ZH-012 — see docs/upstream-issues/02-gateway-caller-principal.md). Until that lands:

  • The plugin reads the caller principal via an optional principalProvider override on buildZohoGatewayAdapter({ principalProvider }). The provider is the ONLY surface the ACL consults — _callerPrincipal has been removed from every tool schema (RT-005 fix) so the LLM cannot forge it via a normal tool-call payload.
  • When allowedSenders is non-empty and no principalProvider is wired, tool calls fail closed (Forbidden [MISSING_PRINCIPAL]). The security adapter surfaces a [CRITICAL] warning and a zoho.acl.advisory audit finding so operators notice.
  • A compromised agent or a prompt-injection payload that breaks out of the agent's own boundary (e.g., by tricking it into changing tool-call arguments) cannot bypass the ACL, because the principal is never taken from a model-supplied field.
  • A compromise of the host process itself, or a flawed host principalProvider implementation, can still defeat the ACL. Treat this layer accordingly.

Until OC-ZH-012 is resolved upstream, hosts SHOULD wire a principalProvider that returns the paired-sender id for the current conversation:

import { buildZohoGatewayAdapter } from "@skavinski/openclaw-zoho";

const zohoGatewayAdapter = buildZohoGatewayAdapter({
  secretAdapter: mySecretAdapter,
  confirmDispatcher: myConfirmDispatcher,
  // Return the paired-sender id for the current tool call, or undefined
  // if it can't be determined (the plugin will fail closed in that case).
  principalProvider: () => currentContext.getPairedSenderPrincipal()
});

npm audit noise (OC-ZH-014)

A fresh install currently surfaces roughly 17 advisories from npm audit. All of them come from the openclaw peer dependency's own transitive tree (Discord, WhatsApp, and Lark SDKs pulled in by the host), not from this plugin's own dependencies.

This plugin's direct runtime dependencies are only @sinclair/typebox and zod (see package.json:52). The files whitelist in package.json:16 ships only dist/, openclaw.plugin.json, LICENSE, and README.md — none of the vulnerable transitive surface is redistributed by this package. The advisories reflect host-side risk owned by openclaw, and are tracked upstream as OC-ZH-014.

agentTools reference

All tool parameter schemas are defined with TypeBox in src/tools/mail-tools.ts, src/tools/calendar-tools.ts, and src/tools/pairing-tool.ts. Every tool accepts an optional accountId when multiple Zoho accounts are registered. The caller principal is not a tool-schema field (RT-005): the LLM cannot set it. Principal is read from the host-supplied principalProvider at execute time — see the advisory-ACL callout above.

Pairing

| Tool | Purpose | Inputs | Output | |-----------------|---------------------------------------------|---------------------------------------------------------------------------------------------|------------------------------------------------------| | zoho.connect | Walk the user through in-chat Zoho pairing. | accountLabel? (free-form label, URL-safe). Secrets are captured via the paired channel, never via LLM-visible params. | { ok, accountLabel, region, scopeSeparator } |

zoho.connect requires interactiveDispatcher, principalProvider, and secretAdapter on the gateway adapter. It fails closed if any is missing.

Mail

| Tool | Purpose | Inputs | Output | |---------------------|-----------------------------------------|--------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------| | zoho.mail.list | List recent inbox messages. | limit? (1–100, default 20), accountId? | { ok, count, items[] } | | zoho.mail.get | Fetch one message by id. | messageId (required), accountId? | { ok, message } | | zoho.mail.send | Send a message. Requires confirmation. | to[] (email), cc?[], bcc?[], subject, body, format? (plaintext|html), from?, accountId? | { ok, messageId } | | zoho.mail.delete | Delete a message (moves to Trash, recoverable). Requires confirmation. | messageId (required), accountId? | { ok, messageId, deleted } |

zoho.mail.send requires fromAddress either in config (account.fromAddress) or passed via the from argument — otherwise the call fails with UnsupportedOperation [NO_FROM_ADDRESS].

zoho.mail.delete calls Zoho's DELETE /api/accounts/{accountId}/messages/{messageId} which moves the message to Trash (Zoho retains it for ~30 days and the user can restore it from the web UI). This is a soft delete, not a permanent purge.

Calendar

| Tool | Purpose | Inputs | Output | |-----------------------------------|---------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------| | zoho.calendar.event.list | List events in a date range. | start, end, accountId? | { ok, count, items[] } | | zoho.calendar.event.get | Fetch one event by id. | eventId, accountId? | { ok, event } | | zoho.calendar.event.create | Create an event. Requires confirmation. | title, startTime, endTime, description?, location?, attendees?[], allDay?, accountId? | { ok, eventId } | | zoho.calendar.event.update | Patch an event. Requires confirmation. | eventId, plus any subset of title, startTime, endTime, description, location, attendees, allDay, accountId? | { ok, eventId } | | zoho.calendar.event.delete | Delete an event. Requires confirmation. | eventId, accountId? | { ok, eventId } |

Tool results follow the pi-agent-core { content, details } convention. Errors surface as { isError: true, content, details: { ok: false, code, retryable, userMessage, requestId } } with the ZohoError taxonomy from src/zoho/errors.ts.

Note on sendText

The channel's outbound adapter (src/adapters/outbound.ts:20) always throws UnsupportedOperation [SEND_TEXT_NOT_SUPPORTED]. Mail delivery exclusively flows through the zoho.mail.send agent tool so that the authz, rate-limit, and confirmation gates cannot be bypassed.

Troubleshooting

  • WSL portproxy drift. The WSL VM's IP changes on each Windows reboot. A netsh interface portproxy rule pinned to yesterday's WSL IP will silently stop forwarding, and OAuth callbacks will hang until the 5-minute loopback timeout fires. Refresh the portproxy rule to the current WSL IP, or pair directly on the host side.
  • OAuth loopback port blocked. Host firewalls sometimes refuse to bind ephemeral ports on 127.0.0.1 — or a corporate proxy intercepts the browser redirect. Symptom: the browser tab shows the Zoho success page but the CLI never receives the callback. Workaround: use the manual paste-back fallback during pairing.
  • Pairing timeout. The loopback listener closes after exactly 5 minutes (src/auth/loopback-server.ts:29). If you were away from the browser, restart the pairing flow.
  • npm audit advisories on install. Expected — see the audit noise section above. None originate in this plugin's own dependency closure.
  • Forbidden [CONFIRMATION_DECLINED] on every write. The host hasn't injected a confirmDispatcher. See the host integration gotcha section.
  • zoho.connect immediately reports "interactiveDispatcher not wired". The host hasn't injected an interactiveDispatcher override — in-chat pairing is impossible without it. See the host integration gotcha section.

Contributing

Issues and patches welcome. Bug tracker URL will be added here once public. The unit-test suite covers the security-critical modules — please run npm run check before submitting a patch.

License

MIT. See LICENSE.

Not affiliated with Zoho. Zoho, Zoho Mail, and Zoho Calendar are trademarks of Zoho Corporation Pvt. Ltd. This project is not endorsed, sponsored, or certified by Zoho. Bring your own credentials — no credentials or user data are transmitted to the plugin author.