Install
$ agentstack add skill-madisonrickert-zoho-books-cli-zoho-books ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Zoho Books CLI (zb): agent usage
A thin command-line wrapper over the Zoho Books v3 REST API designed for agents:
- Outputs a single JSON object on stdout (
{"ok": true, "data": ...}). - Errors go to stderr with a stable
error.codeand a meaningful exit code. - Preserves 19-digit Zoho IDs as strings end-to-end (the MCP loses precision in JS runtimes).
- Native multipart for local file uploads (the MCP can't carry binary).
This file is the agent-user reference. For contributing code to the CLI, see AGENTS.md. For human onboarding, see README.md.
Repo:
When to use this CLI vs. the Zoho MCP
| Task | Use | | ---- | --- | | Upload or download a local file (receipts, bill/invoice attachments, document PDFs) | zb | | CRUD anywhere where the response IDs flow through a JS/Node runtime | zb (preserves 19-digit IDs as strings) | | The two-step workflows below (apply payments to a bill, apply credits to an invoice, write off / cancel write-off, mark sent / void / draft) | zb has them as first-class verbs | | MCP-only conveniences (full-text search, AI suggestions) | MCP | | An endpoint neither wraps | zb raw |
Both surfaces are valid; mix them freely. The IDs zb returns are safe to feed straight into MCP calls.
Preconditions
zb --version # confirms install
zb auth status # → {"ok": true, "data": {"authenticated": true, ...}}
zb org current # → {"ok": true, "data": {"org_id": "...", ...}}
If auth status shows authenticated: false, stop and tell the user to run zb auth login --client-id --client-secret . You cannot complete the OAuth flow on their behalf.
If org current returns a null org_id, run zb org list and ask the user which organization to target, then zb org use .
IDs must be strings in --body JSON
Zoho Books IDs are 19 digits. JavaScript's Number type starts losing precision past ~16 digits (2^53 − 1). When you construct --body JSON, quote IDs as strings:
# Correct
zb expenses create --body '{"account_id":"9820000005670010000","amount":42.50}'
# Dangerous: a JS consumer of the response will silently corrupt the ID
zb expenses create --body '{"account_id":9820000005670010000,"amount":42.50}'
Numbers in JSON bodies (amounts, quantities, dates expressed as ints) are fine; the rule is specifically about ID-shaped fields.
Thin-wrapper convention
Every wrapped command takes one or both of:
--query key=value(repeatable) and / or--params ''(single JSON object) for URL query params. Both merge into the final query dict;--paramswins on conflict.--body ''or--body @path/to/file.jsonfor the request body. IDs in the body must be strings.
--page and --per-page are first-class on every list. Opt-in --page-all (with --page-limit and --page-delay) auto-paginates and emits NDJSON, one JSON line per page.
There are no typed per-field flags, for either filters or body fields. If a flag isn't in --help, it doesn't exist. List filters go through --query / --params using Zoho's documented param names; bodies are built as JSON.
# Wrong: these flags don't exist, so the CLI rejects the call
zb expenses list --date-start 2022-08-10 --date-end 2022-08-15
zb expenses list --status unbilled
# Right: Zoho's documented query params, passed verbatim
zb expenses list --query from_date=2022-08-10 --query to_date=2022-08-15
zb expenses list --params '{"from_date":"2022-08-10","to_date":"2022-08-15"}'
zb expenses list --query status=unbilled --query vendor_id=98200000056700100
Filter param names come from Zoho's API reference for the underlying endpoint (from_date/to_date, status, customer_id, vendor_id, search_text, filter_by, ...) and vary by endpoint (bank-transactions statements use from_date/to_date, some other endpoints use date_start/date_end). A mistyped or wrong-for-this-endpoint filter is silently ignored by Zoho: the response looks fine but is unfiltered. If a filter doesn't seem to take, recheck the param name against Zoho's docs before assuming the data is correct.
Output contract (parse this, not stdout text)
Success → stdout, exit 0:
{"ok": true, "data": {...}}
Error → stderr, non-zero exit:
{"ok": false, "error": {"code": "", "message": "", "details": {...}}}
Stable error.code + exit codes:
| error.code | Exit | Agent response | | ------------ | ---- | -------------- | | auth_required | 2 | Tell user to run zb auth login. Do not retry. | | auth_expired | 2 | Refresh token rejected; user must re-login. | | auth_failed | 2 | Login/refresh exchange failed; check client_id/secret. | | validation | 3 | Bad inputs (missing file, unsupported type, file >10 MB, malformed JSON body). Fix and retry. | | not_found | 4 | The record ID doesn't exist. Surface clearly; offer to list/search. | | api_error | 4 | Zoho returned 4xx. details.zoho_code carries Zoho's specific error code. | | rate_limited | 5 | details.retry_after_s says how long to wait. | | network | 6 | Transient. Retry once; if persistent, surface to user. | | unknown | 1 | Surface the message verbatim. |
Output formatting flags
| Flag | Effect | | ---- | ------ | | --format json (default) | One-line JSON object. | | --format yaml | YAML. | | --format table | Rich table (humans). | | --format csv | CSV (list responses only; falls back to JSON otherwise). | | --dry-run | Print the request that would be sent (method, url, query, body, headers, files) as the success payload. No network I/O, no token refresh. Use to preview destructive calls. |
Command tree (agent-relevant verbs)
> Authoritative tree: zb --list-commands returns the full command list as JSON.
Auth & organization
zb auth login|status|refresh|logout
zb org list|use|current # local config (target org)
zb org get # API: GET /organizations/{id}
zb org update --body '{...}'
Expenses (full surface + binary)
zb expenses list|get|create|update|update-by-custom-field|delete
zb expenses receipt upload|get|delete # single receipt per expense; replaces existing
zb expenses attachments add|delete # multiple supplementary files
zb expenses comments list
Recurring expenses
zb recurring-expenses list|get|create|update|update-by-custom-field|delete
zb recurring-expenses stop|resume
zb recurring-expenses children # child expenses
zb recurring-expenses history # comments / activity
Bank transactions + rules + statements
zb bank-transactions list|get|create|update|delete
zb bank-transactions match|matches|unmatch|exclude|restore|uncategorize
zb bank-transactions categorize generic|expense|vendor-payment|customer-payment|payment-refund|vendor-payment-refund|credit-note-refund|vendor-credit-refund
# `generic` is double-entry: body needs transaction_type + from_account_id + to_account_id + amount + date (see recipe below)
zb bank-transactions statements import|last-imported|delete
zb bank-rules list|get|create|update|delete # /bankaccounts/rules; list requires account_id query param
Bills (full)
zb bills list|get|create|update|update-by-custom-field|delete
zb bills mark-void|mark-open
zb bills email # POST /bills/{id}/email
zb bills payments list|apply|delete # `delete` unapplies a payment row
zb bills comments list
zb bills attachments add|get|delete # `get` writes the file to --output
Invoices (full)
zb invoices list|get|create|update|update-by-custom-field|delete
zb invoices mark-sent|mark-void|mark-draft
zb invoices write-off|cancel-write-off
zb invoices email # ad-hoc email
zb invoices reminders send # uses Zoho's payment-reminder template (placeholders, dunning rules)
zb invoices payments list # read-only; record payments via `customer-payments create`
zb invoices credits list|apply|delete # apply existing credit-notes / customer-payments
zb invoices comments list|add|delete
zb invoices documents get|download|delete # `download` writes bytes via responseformat=pdf|html
zb invoices attachments add|get|delete
zb invoices templates list|apply
zb invoices export -o out.pdf # bulk-export several invoices into one combined PDF
Documents inbox (org-level)
zb documents list # GET /documents (receipts/files uploaded via web UI or autoscan)
zb documents get # metadata, looked up within the listing
zb documents download -o file.pdf # writes the original file bytes (binary-safe)
zb documents delete
This is the standalone Documents module, distinct from invoices documents (which manages files attached to one invoice). GET /documents/{id} returns the raw file bytes, so download streams them straight to --output. There is no JSON metadata single-get endpoint, so get scans the listing (from --page, up to --page-limit pages) and returns the matching row; narrow a large inbox with --per-page or --query filter_by=....
Recurring invoices
zb recurring-invoices list|get|create|update|update-by-custom-field|delete
zb recurring-invoices stop|resume
zb recurring-invoices history
zb recurring-invoices templates apply
Customer payments
zb customer-payments list|get|create|update|update-by-custom-field|delete
zb customer-payments refunds list|get|create|update|delete
Projects (full)
zb projects list|get|create|update|update-by-custom-field|delete
zb projects mark-active|mark-inactive|clone
zb projects invoices # invoices billed against this project
zb projects users list|get|add|invite|update|delete
zb projects tasks list|get|add|update|delete
zb projects comments list|add|delete
Contacts (full)
zb contacts list|search|get|create|update|update-by-custom-field|delete
zb contacts mark-active|mark-inactive
zb contacts comments
zb contacts addresses list|add|update|delete [] ...
zb contacts persons list|get|create|update|delete|mark-primary
Chart of accounts
zb chart-of-accounts list|get|create|update|delete
zb chart-of-accounts mark-active|mark-inactive
zb chart-of-accounts transactions list|delete
Escape hatch
zb raw [--query k=v] [--body ''|@file.json] [--file field=path] [--output file]
--output (GET only) writes the raw response bytes to a file instead of wrapping them in the JSON envelope, so binary endpoints download without corruption (e.g. zb raw GET /documents/ -o receipt.pdf). Without it, a binary body is stringified into data.response and corrupted.
File constraints (validated client-side)
- Types:
.pdf,.jpg,.jpeg,.png,.gif - Size: ≤ 10 MB per file
Multi-file upload semantics
attachments add file1 file2 ... does not abort on the first failure. Iterate data.results[]:
{
"ok": true,
"data": {
"expense_id": "...",
"results": [
{"file": "a.pdf", "ok": true, "response": {...}},
{"file": "b.exe", "ok": false, "error": {"code": "validation", ...}}
]
}
}
receipt upload (one file per expense) returns success/failure as the top-level envelope and replaces any existing receipt.
Idempotency notes
receipt uploadreplaces.attachments addappends: calling twice with the same file creates two attachments.attachments deleteandreceipt deleteare idempotent; deleting an already-deleted item returnsnot_found.- Status verbs (
mark-active,mark-inactive,mark-sent,mark-void,stop,resume, etc.) are server-validated for eligibility. You can call them on a record already in the target state without harm, but the response message will reflect Zoho's view. bills payments deleteandinvoices credits deleteonly unapply an application row; the underlying payment / credit-note record is untouched and re-applicable elsewhere.
Typical agent flows
Attach a receipt to an expense:
zb auth status(verify) →zb org current(verify org_id).zb expenses receipt upload /path/to/file.pdf.- Parse response: on
ok:true, confirmdata.uploaded(filename) anddata.size_bytes. Onerror.code=not_found, offer to list recent expenses to find the right ID.
Email an invoice:
zb invoices email: empty body sends Zoho's default to the saved-on-record recipients.- To override recipients/subject:
--body '{"to_mail_ids":["a@b.com"],"subject":"...","body":"…"}'. - To attach the PDF:
--query send_attachment=true. - For dunning / overdue reminders that pick up the org's payment-reminder template, use
zb invoices reminders sendinstead.
Apply credits to an invoice:
- Discover applicable credits via
zb customer-payments listandzb raw GET /creditnotes. zb invoices credits apply --body '{"apply_creditnotes":[{"creditnote_id":"...","amount_applied":100}]}'.- Inspect the result via
zb invoices credits list.
Categorize an uncategorized bank transaction as an expense:
zb bank-transactions list --query status=uncategorized(or--query account_id=...).zb bank-transactions categorize expense --body '{"account_id":"...","amount":...,"date":"..."}'.
- Credit-card-account transactions: add
"paid_through_account_id":""(the card account) to the body. Without it Zoho returns108004("the account you are trying to match it to is different"), because the expense isn't funded from the card the transaction lives on. - If
108004persists, use the validated two-step fallback:zb expenses create --body '{"account_id":"...","paid_through_account_id":"","amount":...,"date":"..."}', thenzb bank-transactions match --query account_id= --body '{"transactions_to_be_matched":[{"transaction_id":"","transaction_type":"expense"}]}'.
- Confirm via
zb bank-transactions get.
Categorize an uncategorized transaction as income, owner drawings, or a transfer (generic): The generic endpoint posts a double-entry transaction, so the body needs transaction_type plus BOTH from_account_id and to_account_id (a single account_id is rejected with "Invalid value passed for Transaction Type" or "Account does not exist"), with amount and date:
zb bank-transactions categorize generic --body '{"transaction_type":"other_income","from_account_id":"","to_account_id":"","amount":250.00,"date":"2026-06-16"}'
from_account_idis the source of the money;to_account_idis where it landed. Money arriving in the bank means the bank account isto_account_id; money leaving means the bank account isfrom_account_id.transaction_typeis one of:deposit,refund(credit-card accounts only),transfer_fund,card_payment,sales_without_invoices,expense_refund,owner_contribution,interest_income,other_income,owner_drawings,sales_return.- Expense, vendor payment, and customer payment are NOT valid
transaction_typevalues here. Usecategorize expense,categorize vendor-payment, orcategorize customer-payment(each has its own body shape) instead.
- Confirm via
zb bank-transactions get.
Discoverability
zb --list-commands # full tree as JSON
zb --help # per-group help
zb --help # per-command help (shows --body / --query expectations)
Use --list-commands after a CLI upgrade to discover new verbs without re-reading this skill.
Full developer / contributor reference
See AGENTS.md for architecture, contribution patterns, and the test contract (relevant only if you're modif
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: madisonrickert
- Source: madisonrickert/zoho-books-cli
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.