
Zoho
- 1 installs
- 4 repo stars
- Updated February 18, 2026
- shreefentsar/clawdbot-zoho
Interact with Zoho CRM, Projects, and Meeting APIs via a zoho CLI wrapper to manage deals, contacts, tasks, milestones, and meeting recordings.
About
Integrates with the Zoho CRM, Projects, and Meeting APIs through a zoho CLI wrapper that handles OAuth token refresh and caching. A developer uses it to manage deals, contacts, leads, tasks, projects, milestones, and meeting recordings from the agent.
- zoho CLI wrapper auto-refreshes and caches OAuth tokens
- Covers CRM deals/contacts, Projects tasks/milestones, and Meeting recordings
Zoho by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/shreefentsar/clawdbot-zoho --skill zohoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 4 |
| Last updated | February 18, 2026 |
| Repository | shreefentsar/clawdbot-zoho ↗ |
What it does
Interact with Zoho CRM, Projects, and Meeting APIs via a zoho CLI wrapper to manage deals, contacts, tasks, milestones, and meeting recordings.
Files
Zoho Integration (CRM + Projects + Meeting)
Made by Zone 99 · GitHub · Contribute
Quick Start
Use the zoho CLI wrapper — it handles OAuth token refresh and caching automatically.
zoho help # Show all commands
zoho token # Print current access token (auto-refreshes)Authentication Setup
Step 1: Register Your Application
1. Go to Zoho API Console 2. Click Add Client → choose Server-based Applications 3. Fill in:
- Client Name: your app name (e.g. "Clawdbot Zoho Integration")
- Homepage URL: your domain or
https://localhost - Redirect URI:
https://localhost/callback(or any URL you control — you only need it once to grab the code)
4. Click Create 5. Note down the Client ID and Client Secret
Step 2: Generate Authorization Code (Grant Token)
Build this URL and open it in your browser (replace the placeholders):
https://accounts.zoho.com/oauth/v2/auth
?response_type=code
&client_id=YOUR_CLIENT_ID
&scope=ZohoCRM.modules.ALL,ZohoCRM.settings.ALL,ZohoProjects.projects.ALL,ZohoProjects.tasks.ALL,ZohoMeeting.recording.READ,ZohoMeeting.meeting.READ,ZohoMeeting.meetinguds.READ,ZohoFiles.files.READ
&redirect_uri=https://localhost/callback
&access_type=offline
&prompt=consentImportant: Use the accounts URL matching your datacenter:
| Region | Accounts URL |
|--------|-------------|
| US | https://accounts.zoho.com || EU | https://accounts.zoho.eu || IN | https://accounts.zoho.in || AU | https://accounts.zoho.com.au || JP | https://accounts.zoho.jp || UK | https://accounts.zoho.uk || CA | https://accounts.zohocloud.ca || SA | https://accounts.zoho.sa |After granting access, you'll be redirected to something like:
https://localhost/callback?code=1000.abc123...&location=us&accounts-server=https://accounts.zoho.comCopy the code parameter value. This code expires in 2 minutes — move to Step 3 immediately.
Step 3: Exchange Code for Refresh Token
Run this curl command (replace placeholders):
curl -X POST "https://accounts.zoho.com/oauth/v2/token" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "grant_type=authorization_code" \
-d "redirect_uri=https://localhost/callback" \
-d "code=PASTE_CODE_FROM_STEP_2"Response:
{
"access_token": "1000.xxxx.yyyy",
"refresh_token": "1000.xxxx.zzzz",
"api_domain": "https://www.zohoapis.com",
"token_type": "Bearer",
"expires_in": 3600
}Save the refresh_token — this is your long-lived credential. The access token expires in 1 hour, but the CLI auto-refreshes it using the refresh token.
Step 4: Find Your Org IDs
CRM/Projects Org ID:
# After setting up .env with client_id, client_secret, refresh_token:
zoho raw GET /crm/v7/org | jq '.org[0].id'Meeting Org ID: Log into Zoho Meeting → Admin Settings → look for the Organization ID in the URL or settings page. It's different from the CRM org ID.
Step 5: Configure .env
Create .env in the skill directory:
ZOHO_CLIENT_ID=1000.XXXXXXXXXXXXXXXXXXXXXXXXX
ZOHO_CLIENT_SECRET=your_client_secret_here
ZOHO_REFRESH_TOKEN=1000.your_refresh_token_here
ZOHO_ORG_ID=123456789 # CRM/Projects org ID
ZOHO_MEETING_ORG_ID=987654321 # Meeting org ID (different from CRM)
ZOHO_CRM_DOMAIN=https://www.zohoapis.com
ZOHO_PROJECTS_DOMAIN=https://projectsapi.zoho.com/restapi
ZOHO_MEETING_DOMAIN=https://meeting.zoho.com
ZOHO_ACCOUNTS_URL=https://accounts.zoho.comAdjust the domain URLs if you're on a non-US datacenter (e.g..eu,.in,.com.au).
OAuth Scopes Reference
| Scope | Used For |
|---|---|
ZohoCRM.modules.ALL | Read/write CRM records (Deals, Contacts, Leads, etc.) |
ZohoCRM.settings.ALL | Read CRM field definitions and org settings |
ZohoProjects.projects.ALL | Read/write projects |
ZohoProjects.tasks.ALL | Read/write tasks, milestones, bugs, timelogs |
ZohoMeeting.recording.READ | List and access meeting recordings |
ZohoMeeting.meeting.READ | List meetings and session details |
ZohoMeeting.meetinguds.READ | Download recording files |
ZohoFiles.files.READ | Download files (recordings, transcripts) |
You can request fewer scopes if you only need CRM or only need Meeting. The authorization URL scope parameter is comma-separated.
Troubleshooting Auth
- "invalid_code" → The authorization code expired (2 min lifetime). Redo Step 2.
- "invalid_client" → Wrong Client ID, or wrong accounts-server URL for your datacenter.
- "invalid_redirect_uri" → The redirect_uri in the curl must exactly match what you registered in API Console.
- Token refresh fails → Refresh tokens can be revoked. Redo Steps 2–3 to get a new one.
- "Given URL is wrong" → You're hitting the wrong API domain for your datacenter.
CRM Commands
# List records from any module
zoho crm list Deals
zoho crm list Deals "page=1&per_page=5&sort_by=Created_Time&sort_order=desc"
zoho crm list Contacts
zoho crm list Leads
# Get a specific record
zoho crm get Deals 1234567890
# Search with criteria
zoho crm search Deals "(Stage:equals:Closed Won)"
zoho crm search Contacts "(Email:contains:@acme.com)"
zoho crm search Leads "(Lead_Source:equals:Web)"
# Create a record
zoho crm create Contacts '{"data":[{"Last_Name":"Smith","First_Name":"John","Email":"j@co.com"}]}'
zoho crm create Deals '{"data":[{"Deal_Name":"New Project","Stage":"Qualification","Amount":50000}]}'
# Update a record
zoho crm update Deals 1234567890 '{"data":[{"Stage":"Closed Won"}]}'
# Delete a record
zoho crm delete Deals 1234567890CRM Modules
Leads, Contacts, Accounts, Deals, Tasks, Events, Calls, Notes, Products, Quotes, Sales_Orders, Purchase_Orders, Invoices
Search Operators
equals, not_equal, starts_with, contains, not_contains, in, not_in, between, greater_than, less_than
Projects Commands
# List all projects
zoho proj list
# Get project details
zoho proj get 12345678
# Tasks
zoho proj tasks 12345678
zoho proj create-task 12345678 "name=Fix+login+bug&priority=High&start_date=01-27-2026"
zoho proj update-task 12345678 98765432 "percent_complete=50"
# Other
zoho proj milestones 12345678
zoho proj tasklists 12345678
zoho proj bugs 12345678
zoho proj timelogs 12345678Task Fields
name, start_date (MM-DD-YYYY), end_date, priority (None/Low/Medium/High), owner, description, tasklist_id, percent_complete
Meeting Commands
# List all recordings
zoho meeting recordings
zoho meeting recordings | jq '[.recordings[] | {topic, sDate, sTime, durationInMins, erecordingId}]'
# Download a recording (use downloadUrl from recordings list)
zoho meeting download "https://files-accl.zohopublic.com/public?event-id=..." output.mp4
# List meetings/sessions
zoho meeting list
zoho meeting list "fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z"
# Get meeting details
zoho meeting get 1066944216Recording Response Fields
Key fields from zoho meeting recordings:
erecordingId— encrypted recording ID (use for dedup/tracking)topic— meeting titlesDate,sTime— start date/time (human-readable)startTimeinMs— start time as epoch ms (use for date filtering)durationInMins— recording durationdownloadUrl/publicDownloadUrl— MP4 download URLtranscriptionDownloadUrl— Zoho-generated transcript (if available)summaryDownloadUrl— Zoho-generated summary (if available)fileSize/fileSizeInMB— recording file sizestatus— e.g.UPLOADEDmeetingKey— meeting identifiercreatorName— who started the recording
Meeting Recording Pipeline
For automated standup/meeting summarization:
# 1. List recordings, filter by today's date (epoch ms)
zoho meeting recordings | jq --argjson start "$START_MS" --argjson end "$END_MS" \
'[.recordings[] | select(.startTimeinMs >= $start and .startTimeinMs <= $end)]'
# 2. Download recording
zoho meeting download "$DOWNLOAD_URL" /tmp/recording.mp4
# 3. Extract audio
ffmpeg -i /tmp/recording.mp4 -vn -acodec pcm_s16le -ar 16000 -ac 1 /tmp/audio.wav -y
# 4. Transcribe via Gemini Flash API (great for Arabic + English mix)
# See scripts/standup-summarizer.sh for full implementation
# 5. Summarize transcript with Claude/GPT
# 6. Clean up temp filesA complete standup summarizer script is included at scripts/standup-summarizer.sh.
Raw API Calls
For anything not covered by subcommands:
# CRM endpoints
zoho raw GET /crm/v7/settings/fields?module=Deals
zoho raw GET /crm/v7/org
# Meeting endpoints
zoho raw GET "https://meeting.zoho.com/meeting/api/v2/{zsoid}/recordings.json"
# Custom modules
zoho raw GET /crm/v7/Custom_ModuleUsage Patterns
When checking deals/pipeline
zoho crm list Deals "sort_by=Created_Time&sort_order=desc&per_page=10" | jq '.data[] | {Deal_Name, Stage, Amount, Closing_Date}'When checking project progress
zoho proj list | jq '.projects[] | {name, status, id: .id_string}'
zoho proj tasks <project_id> | jq '.tasks[] | {name, status: .status.name, percent_complete, priority}'When creating tasks from conversation
zoho proj create-task <project_id> "name=Task+description&priority=High&start_date=MM-DD-YYYY&end_date=MM-DD-YYYY"When summarizing meeting recordings
# Quick list of recent recordings
zoho meeting recordings | jq '[.recordings[:5] | .[] | {topic, sDate, sTime, durationInMins, fileSize}]'
# Download latest recording
URL=$(zoho meeting recordings | jq -r '.recordings[0].downloadUrl')
zoho meeting download "$URL" /tmp/latest.mp4Rate Limits
- CRM: 100 requests/min
- Projects: varies by plan
- Meeting: standard API limits
- Token refresh: don't call more than needed (cached automatically)
References
- CRM API Fields
- Projects API Endpoints
- Meeting API Reference
ZOHO_CLIENT_ID=your_client_id
ZOHO_CLIENT_SECRET=your_client_secret
ZOHO_REFRESH_TOKEN=your_refresh_token
ZOHO_ORG_ID=your_org_id
ZOHO_MEETING_ORG_ID=your_meeting_org_id
ZOHO_CRM_DOMAIN=https://www.zohoapis.com
ZOHO_PROJECTS_DOMAIN=https://projectsapi.zoho.com/restapi
ZOHO_MEETING_DOMAIN=https://meeting.zoho.com
ZOHO_ACCOUNTS_URL=https://accounts.zoho.com
.env
.token_cache
#!/usr/bin/env bash
# zoho — CLI wrapper for Zoho CRM + Projects APIs
# Handles token refresh, caching, and common operations.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
ENV_FILE="${SKILL_DIR}/.env"
TOKEN_CACHE="${SKILL_DIR}/.token_cache"
# ── Load env ──────────────────────────────────────────────────────────
if [[ -f "$ENV_FILE" ]]; then
set -a; source "$ENV_FILE"; set +a
fi
ZOHO_CRM_DOMAIN="${ZOHO_CRM_DOMAIN:-https://www.zohoapis.com}"
ZOHO_PROJECTS_DOMAIN="${ZOHO_PROJECTS_DOMAIN:-https://projectsapi.zoho.com/restapi}"
ZOHO_MEETING_DOMAIN="${ZOHO_MEETING_DOMAIN:-https://meeting.zoho.com}"
ZOHO_MEETING_ORG_ID="${ZOHO_MEETING_ORG_ID:-}"
ZOHO_ACCOUNTS_URL="${ZOHO_ACCOUNTS_URL:-https://accounts.zoho.com}"
# ── Helpers ───────────────────────────────────────────────────────────
die() { echo "ERROR: $*" >&2; exit 1; }
info() { echo ":: $*" >&2; }
check_creds() {
[[ -n "${ZOHO_CLIENT_ID:-}" ]] || die "ZOHO_CLIENT_ID not set"
[[ -n "${ZOHO_CLIENT_SECRET:-}" ]] || die "ZOHO_CLIENT_SECRET not set"
[[ -n "${ZOHO_REFRESH_TOKEN:-}" ]] || die "ZOHO_REFRESH_TOKEN not set"
}
# ── Token management ─────────────────────────────────────────────────
get_access_token() {
# Check cache (tokens valid for ~55 min, we cache for 50)
if [[ -f "$TOKEN_CACHE" ]]; then
local cached_time cached_token
cached_time=$(head -1 "$TOKEN_CACHE")
cached_token=$(tail -1 "$TOKEN_CACHE")
local now
now=$(date +%s)
local age=$(( now - cached_time ))
if (( age < 3000 )); then
echo "$cached_token"
return
fi
fi
check_creds
local response
response=$(curl -s -X POST "${ZOHO_ACCOUNTS_URL}/oauth/v2/token" \
-d "refresh_token=${ZOHO_REFRESH_TOKEN}" \
-d "client_id=${ZOHO_CLIENT_ID}" \
-d "client_secret=${ZOHO_CLIENT_SECRET}" \
-d "grant_type=refresh_token")
local token
token=$(echo "$response" | jq -r '.access_token // empty')
[[ -n "$token" ]] || die "Token refresh failed: $response"
# Cache it
echo "$(date +%s)" > "$TOKEN_CACHE"
echo "$token" >> "$TOKEN_CACHE"
chmod 600 "$TOKEN_CACHE"
echo "$token"
}
api() {
local method="$1" url="$2"
shift 2
local token
token=$(get_access_token)
curl -s -X "$method" "$url" \
-H "Authorization: Zoho-oauthtoken $token" \
-H "Content-Type: application/json" \
"$@"
}
api_form() {
local method="$1" url="$2"
shift 2
local token
token=$(get_access_token)
curl -s -X "$method" "$url" \
-H "Authorization: Zoho-oauthtoken $token" \
-H "Content-Type: application/x-www-form-urlencoded" \
"$@"
}
# ── CRM helpers ──────────────────────────────────────────────────────
crm_base="${ZOHO_CRM_DOMAIN}/crm/v7"
crm_list() {
local module="$1"; shift
local params=""
[[ $# -gt 0 ]] && params="?$1"
api GET "${crm_base}/${module}${params}"
}
crm_get() {
local module="$1" id="$2"
api GET "${crm_base}/${module}/${id}"
}
crm_search() {
local module="$1" criteria="$2"
api GET "${crm_base}/${module}/search?criteria=${criteria}"
}
crm_create() {
local module="$1" json="$2"
api POST "${crm_base}/${module}" -d "$json"
}
crm_update() {
local module="$1" id="$2" json="$3"
api PUT "${crm_base}/${module}/${id}" -d "$json"
}
crm_delete() {
local module="$1" id="$2"
api DELETE "${crm_base}/${module}/${id}"
}
# ── Projects helpers ─────────────────────────────────────────────────
proj_base() {
[[ -n "${ZOHO_ORG_ID:-}" ]] || die "ZOHO_ORG_ID not set (needed for Projects)"
echo "${ZOHO_PROJECTS_DOMAIN}/portal/${ZOHO_ORG_ID}"
}
proj_list_projects() {
api GET "$(proj_base)/projects/"
}
proj_get_project() {
local id="$1"
api GET "$(proj_base)/projects/${id}/"
}
proj_list_tasks() {
local project_id="$1"; shift
local params=""
[[ $# -gt 0 ]] && params="?$1"
api GET "$(proj_base)/projects/${project_id}/tasks/${params}"
}
proj_create_task() {
local project_id="$1" form_data="$2"
api_form POST "$(proj_base)/projects/${project_id}/tasks/" -d "$form_data"
}
proj_update_task() {
local project_id="$1" task_id="$2" form_data="$3"
api_form POST "$(proj_base)/projects/${project_id}/tasks/${task_id}/" -d "$form_data"
}
proj_list_milestones() {
local project_id="$1"
api GET "$(proj_base)/projects/${project_id}/milestones/"
}
proj_list_tasklists() {
local project_id="$1"
api GET "$(proj_base)/projects/${project_id}/tasklists/"
}
proj_list_bugs() {
local project_id="$1"
api GET "$(proj_base)/projects/${project_id}/bugs/"
}
proj_list_timelogs() {
local project_id="$1"
api GET "$(proj_base)/projects/${project_id}/logs/"
}
# ── Meeting helpers ───────────────────────────────────────────────────
meeting_base() {
[[ -n "${ZOHO_MEETING_ORG_ID:-}" ]] || die "ZOHO_MEETING_ORG_ID not set (needed for Meeting API)"
echo "${ZOHO_MEETING_DOMAIN}/meeting/api/v2/${ZOHO_MEETING_ORG_ID}"
}
meeting_list_recordings() {
api GET "$(meeting_base)/recordings.json"
}
meeting_download_recording() {
local url="$1" output="${2:--}"
local token
token=$(get_access_token)
if [[ "$output" == "-" ]]; then
curl -s -L -H "Authorization: Zoho-oauthtoken $token" "$url"
else
curl -s -L -o "$output" -w "%{http_code}" -H "Authorization: Zoho-oauthtoken $token" "$url"
fi
}
meeting_list_meetings() {
local params=""
[[ $# -gt 0 ]] && params="?$1"
api GET "$(meeting_base)/sessions.json${params}"
}
meeting_get_meeting() {
local meeting_key="$1"
api GET "$(meeting_base)/${meeting_key}.json"
}
# ── Raw API call ─────────────────────────────────────────────────────
# Usage: raw <METHOD> <full_url_or_path> [curl_args...]
# If path starts with /crm → uses CRM domain; /portal or /restapi → Projects domain
raw_api() {
local method="$1" path="$2"
shift 2
if [[ "$path" == http* ]]; then
api "$method" "$path" "$@"
elif [[ "$path" == /crm* ]]; then
api "$method" "${ZOHO_CRM_DOMAIN}${path}" "$@"
elif [[ "$path" == /meeting* ]]; then
api "$method" "${ZOHO_MEETING_DOMAIN}${path}" "$@"
else
api "$method" "${ZOHO_PROJECTS_DOMAIN}${path}" "$@"
fi
}
# ── CLI dispatch ─────────────────────────────────────────────────────
usage() {
cat <<'EOF'
zoho — Zoho CRM + Projects + Meeting CLI
USAGE:
zoho <command> [args...]
TOKEN:
token Print current access token (refreshes if needed)
CRM:
crm list <Module> [params] List records (e.g., Deals, Contacts, Leads)
crm get <Module> <id> Get a single record
crm search <Module> <criteria> Search records (e.g., "(Deal_Name:contains:Acme)")
crm create <Module> <json> Create record(s) — JSON body
crm update <Module> <id> <json> Update a record
crm delete <Module> <id> Delete a record
PROJECTS:
proj list List all projects
proj get <project_id> Get project details
proj tasks <project_id> [params] List tasks in a project
proj create-task <project_id> <form_data> Create task (URL-encoded form)
proj update-task <project_id> <task_id> <form_data> Update task
proj milestones <project_id> List milestones
proj tasklists <project_id> List task lists
proj bugs <project_id> List bugs
proj timelogs <project_id> List time logs
MEETING:
meeting recordings List all meeting recordings
meeting download <url> [file] Download a recording (url from recordings list)
meeting list [params] List meetings/sessions
meeting get <meeting_key> Get meeting details
RAW:
raw <METHOD> <path> [curl_args...] Raw API call (/crm, /meeting, or full URL)
EXAMPLES:
zoho token
zoho crm list Deals "page=1&per_page=5"
zoho crm search Deals "(Stage:equals:Closed Won)"
zoho crm create Contacts '{"data":[{"Last_Name":"Smith","Email":"j@co.com"}]}'
zoho proj list
zoho proj tasks 12345678
zoho proj create-task 12345678 "name=Fix+bug&priority=High"
zoho meeting recordings
zoho meeting recordings | jq '[.recordings[] | {topic, sDate, durationInMins}]'
zoho meeting download "https://files-accl.zohopublic.com/..." recording.mp4
zoho raw GET /crm/v7/settings/fields?module=Deals
EOF
}
cmd="${1:-help}"
shift || true
case "$cmd" in
token)
get_access_token
;;
crm)
sub="${1:-}"; shift || die "crm needs a subcommand (list|get|search|create|update|delete)"
case "$sub" in
list) crm_list "$@" ;;
get) crm_get "$@" ;;
search) crm_search "$@" ;;
create) crm_create "$@" ;;
update) crm_update "$@" ;;
delete) crm_delete "$@" ;;
*) die "Unknown crm subcommand: $sub" ;;
esac
;;
meeting)
sub="${1:-}"; shift || die "meeting needs a subcommand (recordings|download|list|get)"
case "$sub" in
recordings) meeting_list_recordings ;;
download) meeting_download_recording "$@" ;;
list) meeting_list_meetings "$@" ;;
get) meeting_get_meeting "$@" ;;
*) die "Unknown meeting subcommand: $sub" ;;
esac
;;
proj)
sub="${1:-}"; shift || die "proj needs a subcommand"
case "$sub" in
list) proj_list_projects ;;
get) proj_get_project "$@" ;;
tasks) proj_list_tasks "$@" ;;
create-task) proj_create_task "$@" ;;
update-task) proj_update_task "$@" ;;
milestones) proj_list_milestones "$@" ;;
tasklists) proj_list_tasklists "$@" ;;
bugs) proj_list_bugs "$@" ;;
timelogs) proj_list_timelogs "$@" ;;
*) die "Unknown proj subcommand: $sub" ;;
esac
;;
raw)
raw_api "$@"
;;
help|--help|-h)
usage
;;
*)
die "Unknown command: $cmd (try 'zoho help')"
;;
esac
Zoho Skill for Clawdbot
Talk to your Zoho workspace like a human. CRM deals, project tasks, meeting recordings — all through natural conversation with your AI agent.
No more tab-switching between Zoho CRM, Projects, and Meeting. Just ask.
What it does
This skill gives your Clawdbot agent direct access to three Zoho products:
CRM — Search, create, and update deals, contacts, leads, and any other module. Your agent reads your pipeline and acts on it.
Projects — List projects, create tasks, track milestones, log time. Your agent becomes your project manager's best friend.
Meeting — Pull recording lists, download MP4s, and feed them into transcription pipelines. The included standup summarizer script handles the full loop: download → transcribe (Gemini Flash) → summarize.
Real use cases
- "What deals closed this month?" → Agent queries CRM, gives you a summary
- "Create a task in Project X: fix the login bug, high priority, due Friday" → Done
- "Summarize today's standup recording" → Downloads from Zoho Meeting, transcribes via Gemini, gives you bullet points
- "Show me all leads from web signups" → Searches CRM with the right filters
- "How's Project Alpha going?" → Pulls task completion stats, flags overdue items
- "Log 2 hours on the API integration task" → Posts a timelog entry
What's included
zoho/
├── SKILL.md # Agent instructions (how to use the CLI)
├── bin/zoho # CLI wrapper — handles OAuth, token refresh, caching
├── scripts/
│ └── standup-summarizer.sh # Full meeting recording → summary pipeline
└── references/
├── crm-api.md # CRM field definitions
├── projects-api.md # Projects endpoint reference
└── meeting-api.md # Meeting API referenceQuick start
1. Install via ClawdHub
clawdhub install zoho2. Register a Zoho API app
Go to Zoho API Console → Add Client → Server-based Application.
Set the redirect URI to https://localhost/callback.
3. Get your refresh token
The SKILL.md has step-by-step instructions for the OAuth flow. It takes about 3 minutes — you generate an auth code, exchange it for a refresh token, and you're set. The CLI handles token refresh automatically after that.
4. Configure .env
Create a .env file in the skill directory:
ZOHO_CLIENT_ID=1000.XXXXX
ZOHO_CLIENT_SECRET=your_secret
ZOHO_REFRESH_TOKEN=1000.your_refresh_token
ZOHO_ORG_ID=123456789
ZOHO_MEETING_ORG_ID=987654321
ZOHO_CRM_DOMAIN=https://www.zohoapis.com
ZOHO_PROJECTS_DOMAIN=https://projectsapi.zoho.com/restapi
ZOHO_MEETING_DOMAIN=https://meeting.zoho.com
ZOHO_ACCOUNTS_URL=https://accounts.zoho.comAdjust domains for your datacenter (EU, IN, AU, etc.). See SKILL.md for the full region table.
CLI usage
The zoho CLI works standalone too — you don't need Clawdbot to use it.
zoho help # All commands
zoho crm list Deals # List CRM deals
zoho crm search Deals "(Stage:equals:Qualification)"
zoho crm create Contacts '{"data":[{"Last_Name":"Smith","Email":"j@co.com"}]}'
zoho proj list # List projects
zoho proj tasks <project-id> # List tasks
zoho meeting recordings # List meeting recordings
zoho raw GET /crm/v7/org # Raw API callsStandup summarizer
The included standup-summarizer.sh automates daily meeting summaries:
1. Pulls today's recordings from Zoho Meeting 2. Downloads the MP4 3. Extracts audio and transcribes via Gemini Flash API (handles Arabic + English mix) 4. Outputs a structured summary
./scripts/standup-summarizer.sh # Today's recordings
./scripts/standup-summarizer.sh --date 2026-01-28 # Specific dateWorks great as a cron job for automated daily standups.
Supported Zoho regions
US, EU, IN, AU, JP, UK, CA, SA — just swap the domain URLs in your .env.
Rate limits
- CRM: 100 requests/min
- Projects: varies by plan
- Meeting: standard API limits
- Token refresh is cached — no wasted calls
Contributing
Found a bug? Want to add Zoho Books, Desk, or another product? PRs welcome.
1. Fork the repo 2. Create a feature branch 3. Submit a PR with a clear description
Open an issue if you're unsure about something — happy to discuss before you write code.
GitHub: github.com/shreefentsar/clawdbot-zoho
License
MIT
---
Made by the Zone 99 team · 99.zone
Zoho CRM API Reference
Modules
- Leads, Contacts, Accounts, Deals, Tasks, Events, Calls, Notes, Products, Quotes, Sales_Orders, Purchase_Orders, Invoices
Common Fields (Deals)
- Deal_Name, Amount, Stage, Closing_Date, Account_Name, Contact_Name, Pipeline, Probability, Description
Common Fields (Contacts)
- First_Name, Last_Name, Email, Phone, Mobile, Account_Name, Title, Department
Common Fields (Leads)
- First_Name, Last_Name, Email, Company, Phone, Lead_Source, Lead_Status, Industry
Search Criteria Operators
- equals, not_equal, starts_with, contains, not_contains, in, not_in, between, greater_than, less_than
Pagination
pageandper_pageparams (max 200 per page)- Response includes
info.more_recordsboolean
Sorting
sort_byandsort_order(asc/desc)
Zoho Meeting API Reference
Base URL
https://meeting.zoho.com/meeting/api/v2/{zsoid}/Where {zsoid} is the Meeting Organization ID (ZOHO_MEETING_ORG_ID).
Authentication
All requests require Authorization: Zoho-oauthtoken {access_token} header.
Endpoints
Get All Recordings
GET /meeting/api/v2/{zsoid}/recordings.jsonScope: ZohoMeeting.recording.READ
Response:
{
"recordings": [
{
"erecordingId": "encrypted-id",
"topic": "Meeting Title",
"sDate": "Wed, 2 Jul",
"sTime": "09:15 AM",
"datenTime": "Wed Jul 2, 09:15 AM EET",
"startTimeinMs": 1751436903672,
"duration": 77268,
"durationInMins": 1,
"fileSize": "1 MB",
"fileSizeInMB": "1",
"status": "UPLOADED",
"downloadUrl": "https://files-accl.zohopublic.com/...",
"publicDownloadUrl": "https://files-accl.zohopublic.com/...",
"playUrl": "https://meeting.zoho.com/meeting/videoprv?...",
"shareUrl": "https://meeting.zoho.com/meeting/public/videoprv?...",
"recordingEmbedUrl": "https://meeting.zoho.com/meeting/videoprv?...&view=embed",
"meetingKey": "1066944216",
"short_meeting_key": "1066944216",
"recordingId": "4455162000001011067",
"creatorName": "User Name",
"isMeeting": true,
"noAudioRecording": false,
"isTranscriptionEnabled": true,
"isTranscriptGenerated": false,
"transcriptionDownloadUrl": "https://download.zoho.com/...",
"isSummaryGenerated": false,
"summaryDownloadUrl": "https://files.zoho.com/...",
"downloadAccess": 1,
"summaryAccess": 1,
"transcriptAccess": 1,
"shareOption": 0
}
]
}Download Recording
GET {downloadUrl}
Authorization: Zoho-oauthtoken {token}Scope: ZohoMeeting.meetinguds.READ, ZohoFiles.files.READ
Returns the MP4 file binary. Follow redirects (-L in curl).
List Meetings
GET /meeting/api/v2/{zsoid}/sessions.jsonScope: ZohoMeeting.meeting.READ
Get Meeting Details
GET /meeting/api/v2/{zsoid}/{meetingKey}.jsonScope: ZohoMeeting.meeting.READ
Key Fields for Filtering
| Field | Type | Description |
|---|---|---|
startTimeinMs | number | Epoch milliseconds — use for date range filtering |
erecordingId | string | Encrypted ID — use for deduplication |
durationInMins | number | Duration in minutes |
isMeeting | boolean | true for meetings, false for webinars |
noAudioRecording | boolean | true if recording has no audio |
status | string | "UPLOADED" when ready for download |
Notes
- Recording list returns all recordings, not paginated by default
- Downloads may use different domains (files-accl.zohopublic.com, download.zoho.com)
- Some recordings have Zoho-generated transcripts/summaries (check
isTranscriptGenerated,isSummaryGenerated) - Meeting Org ID is different from CRM Org ID — check Zoho Meeting admin settings
Zoho Projects API Reference
Endpoints
Projects
- GET
/projects/— List all projects - GET
/projects/{id}/— Get project details - POST
/projects/— Create project - PUT
/projects/{id}/— Update project - DELETE
/projects/{id}/— Delete project
Tasks
- GET
/projects/{id}/tasks/— List tasks - POST
/projects/{id}/tasks/— Create task - PUT
/projects/{id}/tasks/{task_id}/— Update task - DELETE
/projects/{id}/tasks/{task_id}/— Delete task
Milestones
- GET
/projects/{id}/milestones/— List milestones - POST
/projects/{id}/milestones/— Create milestone
Task Lists
- GET
/projects/{id}/tasklists/— List task lists - POST
/projects/{id}/tasklists/— Create task list
Timesheets
- GET
/projects/{id}/logs/— List time logs - POST
/projects/{id}/tasks/{task_id}/logs/— Add time log
Bugs
- GET
/projects/{id}/bugs/— List bugs - POST
/projects/{id}/bugs/— Create bug
Task Fields
- name, start_date (MM-DD-YYYY), end_date, priority (None/Low/Medium/High), owner, description, tasklist_id, percent_complete
Project Fields
- name, description, status (active/archived), start_date, end_date, owner
#!/usr/bin/env bash
# standup-summarizer.sh — Pull Zoho Meeting recordings, transcribe via Gemini, summarize
# Usage: ./standup-summarizer.sh [--date YYYY-MM-DD] [--force-id <erecordingId>]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
ZOHO_SKILL_DIR="${ZOHO_SKILL_DIR:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
PROCESSED_FILE="${ZOHO_DATA_DIR:-${ZOHO_SKILL_DIR}/data}/standup-processed.json"
TMP_DIR="/tmp/standup-$$"
# ── Load env ──────────────────────────────────────────────────────────
source "${ZOHO_SKILL_DIR}/.env"
GEMINI_API_KEY="${GEMINI_API_KEY:-}"
ZOHO_MEETING_ORG_ID="${ZOHO_MEETING_ORG_ID:-853106938}"
[[ -n "$GEMINI_API_KEY" ]] || { echo "ERROR: GEMINI_API_KEY not set" >&2; exit 1; }
# ── Args ──────────────────────────────────────────────────────────────
TARGET_DATE=""
FORCE_ID=""
while [[ $# -gt 0 ]]; do
case "$1" in
--date) TARGET_DATE="$2"; shift 2 ;;
--force-id) FORCE_ID="$2"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
# Default: today (Cairo time = UTC+2)
if [[ -z "$TARGET_DATE" ]]; then
TARGET_DATE=$(TZ="Africa/Cairo" date +%Y-%m-%d)
fi
# Convert target date to epoch range (Cairo timezone)
TARGET_START_MS=$(TZ="Africa/Cairo" date -d "${TARGET_DATE} 00:00:00" +%s)000
TARGET_END_MS=$(TZ="Africa/Cairo" date -d "${TARGET_DATE} 23:59:59" +%s)999
echo "📅 Target date: ${TARGET_DATE} (Cairo)"
echo "⏰ Range: ${TARGET_START_MS} - ${TARGET_END_MS}"
# ── Ensure dirs ──────────────────────────────────────────────────────
mkdir -p "$TMP_DIR" "$(dirname "$PROCESSED_FILE")"
[[ -f "$PROCESSED_FILE" ]] || echo '[]' > "$PROCESSED_FILE"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
# ── Get Zoho access token ────────────────────────────────────────────
get_token() {
"${ZOHO_SKILL_DIR}/bin/zoho" token
}
# ── Fetch recordings ────────────────────────────────────────────────
echo "🔍 Fetching recordings from Zoho Meeting..."
TOKEN=$(get_token)
RECORDINGS=$(curl -s -X GET \
"https://meeting.zoho.com/meeting/api/v2/${ZOHO_MEETING_ORG_ID}/recordings.json" \
-H "Authorization: Zoho-oauthtoken ${TOKEN}" \
-H "Content-Type: application/json")
# Check for errors
if echo "$RECORDINGS" | jq -e '.error' &>/dev/null; then
echo "ERROR: Zoho API error: $(echo "$RECORDINGS" | jq -r '.error')" >&2
exit 1
fi
# ── Filter today's recordings ───────────────────────────────────────
echo "🔎 Filtering for date: ${TARGET_DATE}..."
if [[ -n "$FORCE_ID" ]]; then
TODAY_RECORDINGS=$(echo "$RECORDINGS" | jq --arg fid "$FORCE_ID" \
'[.recordings[] | select(.erecordingId == $fid)]')
else
TODAY_RECORDINGS=$(echo "$RECORDINGS" | jq --argjson start "$TARGET_START_MS" --argjson end "$TARGET_END_MS" \
'[.recordings[] | select(.startTimeinMs >= $start and .startTimeinMs <= $end)]')
fi
RECORDING_COUNT=$(echo "$TODAY_RECORDINGS" | jq 'length')
echo "📊 Found ${RECORDING_COUNT} recording(s) for ${TARGET_DATE}"
if [[ "$RECORDING_COUNT" -eq 0 ]]; then
echo '{"status":"no_recordings","date":"'"${TARGET_DATE}"'","count":0}'
exit 0
fi
# ── Process each recording ──────────────────────────────────────────
RESULTS="[]"
for i in $(seq 0 $((RECORDING_COUNT - 1))); do
REC=$(echo "$TODAY_RECORDINGS" | jq ".[$i]")
EREC_ID=$(echo "$REC" | jq -r '.erecordingId')
TOPIC=$(echo "$REC" | jq -r '.topic')
DURATION_MINS=$(echo "$REC" | jq -r '.durationInMins')
DOWNLOAD_URL=$(echo "$REC" | jq -r '.downloadUrl // .publicDownloadUrl')
FILE_SIZE=$(echo "$REC" | jq -r '.fileSize // .FileSize')
START_MS=$(echo "$REC" | jq -r '.startTimeinMs')
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📹 Recording: ${TOPIC}"
echo " Duration: ${DURATION_MINS} min | Size: ${FILE_SIZE}"
echo " ID: ${EREC_ID}"
# Check if already processed
if jq -e --arg id "$EREC_ID" '.[] | select(. == $id)' "$PROCESSED_FILE" &>/dev/null; then
echo " ⏭️ Already processed, skipping"
continue
fi
# Download recording
echo " ⬇️ Downloading..."
TOKEN=$(get_token)
MP4_FILE="${TMP_DIR}/recording_${i}.mp4"
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$MP4_FILE" -L \
-H "Authorization: Zoho-oauthtoken ${TOKEN}" \
"$DOWNLOAD_URL")
if [[ "$HTTP_CODE" != "200" ]] || [[ ! -s "$MP4_FILE" ]]; then
echo " ❌ Download failed (HTTP ${HTTP_CODE})"
# Try public download URL if different
PUB_URL=$(echo "$REC" | jq -r '.publicDownloadUrl // empty')
if [[ -n "$PUB_URL" && "$PUB_URL" != "$DOWNLOAD_URL" ]]; then
echo " 🔄 Trying public URL..."
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$MP4_FILE" -L "$PUB_URL")
fi
if [[ "$HTTP_CODE" != "200" ]] || [[ ! -s "$MP4_FILE" ]]; then
echo " ❌ Download failed completely"
continue
fi
fi
ACTUAL_SIZE=$(du -h "$MP4_FILE" | cut -f1)
echo " ✅ Downloaded: ${ACTUAL_SIZE}"
# Extract audio
echo " 🎵 Extracting audio..."
WAV_FILE="${TMP_DIR}/audio_${i}.wav"
ffmpeg -i "$MP4_FILE" -vn -acodec pcm_s16le -ar 16000 -ac 1 "$WAV_FILE" -y -loglevel error 2>&1
if [[ ! -s "$WAV_FILE" ]]; then
echo " ❌ Audio extraction failed"
continue
fi
WAV_SIZE=$(du -h "$WAV_FILE" | cut -f1)
echo " ✅ Audio extracted: ${WAV_SIZE}"
# Check if audio is too large for Gemini inline (20MB limit for audio)
WAV_BYTES=$(stat -c%s "$WAV_FILE")
# Transcribe via Gemini Flash
echo " 🧠 Transcribing via Gemini Flash..."
if [[ "$WAV_BYTES" -gt 20000000 ]]; then
# Large file: upload via File API first
echo " 📤 Large file — uploading to Gemini File API..."
UPLOAD_RESP=$(curl -s -X POST \
"https://generativelanguage.googleapis.com/upload/v1beta/files?key=${GEMINI_API_KEY}" \
-H "X-Goog-Upload-Command: start, upload, finalize" \
-H "X-Goog-Upload-Header-Content-Length: ${WAV_BYTES}" \
-H "X-Goog-Upload-Header-Content-Type: audio/wav" \
-H "Content-Type: audio/wav" \
--data-binary "@${WAV_FILE}")
FILE_URI=$(echo "$UPLOAD_RESP" | jq -r '.file.uri // empty')
if [[ -z "$FILE_URI" ]]; then
echo " ❌ File upload failed: $(echo "$UPLOAD_RESP" | jq -r '.error.message // "unknown"')"
continue
fi
echo " ✅ Uploaded: ${FILE_URI}"
GEMINI_BODY=$(jq -n \
--arg uri "$FILE_URI" \
'{
"contents": [{
"parts": [
{"file_data": {"mime_type": "audio/wav", "file_uri": $uri}},
{"text": "Transcribe this meeting recording. The speakers are Egyptian and speak in Egyptian Arabic mixed with English technical terms. Provide a faithful transcription preserving the language as spoken (Arabic parts in Arabic script, English parts in English). Include speaker changes where detectable. Do NOT summarize — provide the full transcription."}
]
}],
"generationConfig": {"temperature": 0.1, "maxOutputTokens": 8192}
}')
else
# Small file: inline base64 — write to temp file to avoid arg-list-too-long
B64_FILE="${TMP_DIR}/audio_${i}.b64"
base64 -w0 "$WAV_FILE" > "$B64_FILE"
PROMPT_TEXT="Transcribe this meeting recording. The speakers are Egyptian and speak in Egyptian Arabic mixed with English technical terms. Provide a faithful transcription preserving the language as spoken (Arabic parts in Arabic script, English parts in English). Include speaker changes where detectable. Do NOT summarize — provide the full transcription."
GEMINI_BODY=$(jq -n --rawfile audio "$B64_FILE" --arg prompt "$PROMPT_TEXT" \
'{
"contents": [{
"parts": [
{"inline_data": {"mime_type": "audio/wav", "data": $audio}},
{"text": $prompt}
]
}],
"generationConfig": {"temperature": 0.1, "maxOutputTokens": 8192}
}')
rm -f "$B64_FILE"
fi
GEMINI_BODY_FILE="${TMP_DIR}/gemini_body_${i}.json"
echo "$GEMINI_BODY" > "$GEMINI_BODY_FILE"
GEMINI_RESP=$(curl -s -X POST \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}" \
-H "Content-Type: application/json" \
-d "@${GEMINI_BODY_FILE}")
rm -f "$GEMINI_BODY_FILE"
TRANSCRIPT=$(echo "$GEMINI_RESP" | jq -r '.candidates[0].content.parts[0].text // empty')
if [[ -z "$TRANSCRIPT" ]]; then
echo " ❌ Transcription failed: $(echo "$GEMINI_RESP" | jq -r '.error.message // "unknown"')"
# Try with gemini-2.5-flash as fallback
echo " 🔄 Retrying with gemini-2.5-flash..."
GEMINI_BODY_FILE="${TMP_DIR}/gemini_body_${i}.json"
echo "$GEMINI_BODY" > "$GEMINI_BODY_FILE"
GEMINI_RESP=$(curl -s -X POST \
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}" \
-H "Content-Type: application/json" \
-d "@${GEMINI_BODY_FILE}")
rm -f "$GEMINI_BODY_FILE"
TRANSCRIPT=$(echo "$GEMINI_RESP" | jq -r '.candidates[0].content.parts[0].text // empty')
if [[ -z "$TRANSCRIPT" ]]; then
echo " ❌ Transcription failed on both models"
continue
fi
fi
TRANSCRIPT_LEN=${#TRANSCRIPT}
echo " ✅ Transcribed: ${TRANSCRIPT_LEN} chars"
# Save transcript
TRANSCRIPT_FILE="${TMP_DIR}/transcript_${i}.txt"
echo "$TRANSCRIPT" > "$TRANSCRIPT_FILE"
# Build result JSON
RESULT=$(jq -n \
--arg id "$EREC_ID" \
--arg topic "$TOPIC" \
--arg duration "$DURATION_MINS" \
--arg date "$TARGET_DATE" \
--arg start_ms "$START_MS" \
--arg transcript "$TRANSCRIPT" \
'{
"erecordingId": $id,
"topic": $topic,
"durationMins": ($duration | tonumber),
"date": $date,
"startMs": ($start_ms | tonumber),
"transcript": $transcript
}')
RESULTS=$(echo "$RESULTS" | jq --argjson r "$RESULT" '. + [$r]')
# Mark as processed
jq --arg id "$EREC_ID" '. + [$id]' "$PROCESSED_FILE" > "${PROCESSED_FILE}.tmp"
mv "${PROCESSED_FILE}.tmp" "$PROCESSED_FILE"
echo " ✅ Done processing"
# Clean up large files immediately to save disk
rm -f "$MP4_FILE" "$WAV_FILE"
done
# ── Output results ──────────────────────────────────────────────────
RESULT_COUNT=$(echo "$RESULTS" | jq 'length')
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ Processed ${RESULT_COUNT} recording(s)"
if [[ "$RESULT_COUNT" -eq 0 ]]; then
echo '{"status":"nothing_new","date":"'"${TARGET_DATE}"'","count":0}'
exit 0
fi
# Output the full results JSON for the caller to summarize
echo "---RESULTS_JSON_START---"
echo "$RESULTS" | jq '.'
echo "---RESULTS_JSON_END---"
{
"name": "zoho",
"version": "1.3.0",
"description": "Interact with Zoho CRM, Projects, and Meeting APIs. Manage deals, contacts, leads, tasks, projects, milestones, meeting recordings, and standups.",
"author": "shreefentsar",
"tags": ["zoho", "crm", "projects", "meetings", "standup", "recordings"],
"env": [
{
"name": "ZOHO_CLIENT_ID",
"description": "Zoho OAuth2 Client ID (from api-console.zoho.com)",
"required": true,
"secret": true
},
{
"name": "ZOHO_CLIENT_SECRET",
"description": "Zoho OAuth2 Client Secret",
"required": true,
"secret": true
},
{
"name": "ZOHO_REFRESH_TOKEN",
"description": "Zoho OAuth2 Refresh Token",
"required": true,
"secret": true
},
{
"name": "ZOHO_ORG_ID",
"description": "Zoho CRM Organization ID",
"required": true,
"secret": false
},
{
"name": "ZOHO_MEETING_ORG_ID",
"description": "Zoho Meeting Organization ID",
"required": false,
"secret": false
},
{
"name": "GEMINI_API_KEY",
"description": "Google Gemini API key — used by standup-summarizer.sh to transcribe meeting audio via Gemini 2.0 Flash",
"required": false,
"secret": true
},
{
"name": "ZOHO_DATA_DIR",
"description": "Optional: override directory for persistent data files (e.g. standup-processed.json). Defaults to <skill_dir>/data/",
"required": false,
"secret": false
}
],
"files": [
"SKILL.md",
"README.md",
".env.example",
"bin/zoho",
"scripts/standup-summarizer.sh",
"references/"
],
"notes": "Requires the bin/zoho CLI wrapper to be executable (chmod +x bin/zoho). The standup-summarizer.sh script requires GEMINI_API_KEY for audio transcription and writes state to ZOHO_DATA_DIR (configurable)."
}