references/api-gateway.md
## Overview
API Gateway provides routing, rate limiting, auth enforcement, and CORS at the gateway level for all Advanced I/O functions.
---
## CLI Commands
```bash
catalyst apig:enable # Enable API Gateway for the project
catalyst apig:status # Check current status
catalyst apig:disable # Disable API Gateway
```
---
## Route Configuration
Configure in Console → Cloud Scale → API Gateway:
| Field | Description |
|-------|-------------|
| **Path pattern** | URL path with wildcards (e.g., `/api/users/*`) |
| **Target function** | Which Advanced I/O function handles the route |
| **Authentication** | `required` or `optional` |
| **Rate limit** | Max requests/time window |
### Example Route Table
| Path | Target | Auth | Rate Limit |
|------|--------|------|------------|
| `/api/users/*` | `user_api` | required | 100 req/min |
| `/api/public/*` | `public_api` | optional | 50 req/min |
| `/api/admin/*` | `admin_api` | required | 20 req/min |
---
## Rate Limiting
Two throttle types:
- **General**: Max hits/time for all users combined
- **IP-based**: Max hits/IP/time
Algorithm: **Sliding window** (not fixed-window buckets).
Returns `429 Too Many Requests` when limit exceeded.
**Client-side handling:**
```javascript
async function callWithRetry(url, options, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const res = await fetch(url, options);
if (res.status !== 429) return res;
// Exponential backoff: 1s, 2s, 4s
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
}
throw new Error('Rate limit exceeded after retries');
}
```
---
## CORS via API Gateway
Catalyst API Gateway injects `Access-Control-Allow-Origin` for origins configured in:
**Console → Authentication → Whitelisting → Authorized Domains** → enable CORS toggle.
> **Do NOT set CORS headers in your function code for production origins.** Duplicating the header causes browsers to reject the response with a "multiple values" CORS error.
Only set CORS headers in function code for `localhost` (local dev, where no gateway exists):
```javascript
app.use((req, res, next) => {
const origin = req.headers.origin || '';
if (/^http:\/\/localhost(:\d+)?$/.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') return res.status(204).end();
}
next();
});
```
---
## Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| `429 Too Many Requests` | Client exceeded rate limit | Implement exponential backoff; increase limit in Console → API Gateway config |
| Routes returning `404` for valid function paths | API Gateway disabled, wrong route pattern, or function name mismatch | Run `catalyst apig:status` to confirm enabled; verify route path wildcards; confirm function name in route matches deployed function name exactly |
references/functions-advanced.md
## Template Types: Express vs Raw-http
Advanced I/O functions support two templates. **The template is chosen at function creation
and determines the handler API surface.**
| | **Express template** | **Raw-http template** |
|---|---|---|
| Request | `req.query`, `req.params`, `req.body` (Express) | `new URL(req.url, base).searchParams` |
| Response | `res.status(200).json({})` | `res.writeHead(200); res.end(...)` |
| Middleware | `app.use(...)` works | No middleware — plain `http.IncomingMessage` |
| Use when | Familiar Express API | Minimal footprint, raw stream control |
> The examples in this file are labelled with their template type.
---
## File Upload via Advanced I/O Function (busboy)
<!-- Express template -->
Parse `multipart/form-data` file uploads using `busboy`. Install: `npm install busboy`.
```javascript
// Express template
const Busboy = require('busboy');
const catalyst = require('zcatalyst-sdk-node');
module.exports = async (catalystApp, context, req, res) => {
const busboy = Busboy({ headers: req.headers });
const chunks = [];
let fileName = '';
let mimeType = '';
await new Promise((resolve, reject) => {
busboy.on('file', (fieldname, file, info) => {
fileName = info.filename;
mimeType = info.mimeType;
file.on('data', chunk => chunks.push(chunk));
file.on('end', resolve);
file.on('error', reject);
});
busboy.on('error', reject);
req.pipe(busboy);
});
const fileBuffer = Buffer.concat(chunks);
// Upload to Stratus
const { Readable } = require('stream');
const stream = Readable.from(fileBuffer);
await catalystApp.stratus().bucket('myapp-files-70699')
.putObject(`uploads/${fileName}`, stream);
res.status(200).json({ status: 'uploaded', fileName });
};
```
> Use `"advancedio"` (lowercase, no space) as the `type` value in `catalyst-config.json`.
```json
{
"deployment": {
"name": "file_upload",
"type": "advancedio",
"stack": "node20",
"env_variables": {}
},
"execution": {
"main": "index.js"
}
}
```
> `authentication`, `memory`, and `max_time` are **not** `catalyst-config.json` fields — configure them in the Catalyst console under Functions → Security Rules / Configuration.
---
## Stream a File from Stratus to Response
```javascript
// Express template
module.exports = async (catalystApp, context, req, res) => {
const key = req.query.file;
if (!key) return res.status(400).json({ error: 'file param required' });
const bucket = catalystApp.stratus().bucket('myapp-files-70699');
// HEAD check first
const head = await bucket.headObject(key, { throwErr: false });
if (!head) return res.status(404).json({ error: 'File not found' });
res.setHeader('Content-Type', head.content_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${key.split('/').pop()}"`);
const stream = await bucket.getObject(key);
stream.pipe(res);
};
```
---
## Error Handling Pattern
Standard error response pattern for Advanced I/O functions:
```javascript
// Express template
module.exports = async (catalystApp, context, req, res) => {
try {
// ... your logic
const result = await someOperation();
res.status(200).json({ data: result });
} catch (err) {
console.error(JSON.stringify({
action: 'myFunction',
error: err.message,
stack: err.stack
}));
// Classify error type
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
if (err.status === 404 || err.message?.includes('not found')) {
return res.status(404).json({ error: 'Resource not found' });
}
res.status(500).json({ error: 'Internal server error' });
}
};
```
---
## CORS for Local Dev
**This pattern is for the Express template only.**
**Do NOT add CORS headers in function code for production origins** — the Catalyst gateway handles this.
Only set CORS for `localhost` (local dev where no gateway exists):
```javascript
// Express template
app.use((req, res, next) => {
const origin = req.headers.origin || '';
if (/^http:\/\/localhost(:\d+)?$/.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') return res.status(204).end();
}
next();
});
```
---
## Testing Functions Locally (Mock Pattern)
Mock `http.ServerResponse` for unit testing Basic I/O functions:
```javascript
// test/myFunction.test.js
const handler = require('../functions/my_function/index.js');
async function runTest() {
// Mock context (getArgument() lives on context, not basicIO)
const context = {
closeWithSuccess: () => console.log('SUCCESS'),
closeWithFailure: (msg) => console.error('FAILURE:', msg),
getArgument: () => JSON.stringify({ userId: '12345', action: 'test' })
};
// Mock basicIO (write() — not setOutput())
const basicIO = {
write: (result) => console.log('OUTPUT:', result)
};
await handler(context, basicIO);
}
runTest().catch(console.error);
```
For Advanced I/O (Express template), test against the actual local dev server:
```bash
catalyst serve
curl -X POST http://localhost:3000/server/my_function/execute \
-H "Content-Type: application/json" \
-d '{"key": "value"}'
```
---
## Chaining Functions (Call One Function from Another)
**Anti-pattern:** Never call Advanced I/O functions via HTTP from other functions in production.
**Preferred patterns:**
1. **Shared module**: Extract common logic into `functions/utils/` and import it
2. **Circuits**: For multi-step orchestration
3. **Job Scheduling**: For async fan-out
```javascript
// functions/utils/dataHelper.js
async function getUserById(catalystApp, userId) {
const rows = await catalystApp.zcql().executeZCQLQuery(
`SELECT * FROM Users WHERE ROWID = '${userId}'`
);
return rows[0]?.Users || null;
}
module.exports = { getUserById };
```
```javascript
// functions/my_function/index.js
const { getUserById } = require('../utils/dataHelper');
module.exports = async (catalystApp, context, req, res) => {
const user = await getUserById(catalystApp, req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ user });
};
```
---
## Result Unwrapping (ZCQL)
ZCQL result rows are wrapped — always unwrap before accessing column values:
```javascript
const rows = await catalystApp.zcql().executeZCQLQuery('SELECT * FROM Tasks');
// Each row is: { Tasks: { ROWID: '...', Title: '...', ... } }
const tasks = rows.map(row => row.Tasks); // ← Unwrap the table name wrapper
// For JOINs
const joinRows = await catalystApp.zcql().executeZCQLQuery(
'SELECT * FROM Tasks JOIN Users ON Tasks.UserId = Users.ROWID'
);
const items = joinRows.map(row => ({ task: row.Tasks, user: row.Users }));
```
---
## HTTP Payload Limits
| Function Type | Max Request Body | Max Response Body |
|--------------|-----------------|------------------|
| Advanced I/O | 250 MB | 250 MB |
| Basic I/O | 250 MB | 250 MB |
| AppSail | No explicit limit (configurable) | No explicit limit |
For large uploads, consider using pre-signed Stratus URLs for direct browser → Stratus upload (bypasses the function entirely).
## Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| `busboy` never emits `finish` event | Pipe not set up before response end | Ensure `req.pipe(bb)` and `finish` listener registered before piping |
| File upload silently truncated | Function memory limit exceeded mid-stream | Use pre-signed Stratus URL for files > 50 MB |
| Chained function call times out | Inner function cold start exceeds outer timeout | Use `invokeType: 'async'` for fire-and-forget; Job functions for long pipelines |
| `Cannot read properties of undefined (reading 'files')` | `express-fileupload` not added as middleware before route | Add `app.use(fileUpload())` before route definitions |
```javascript
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = (event, context) => {
try {
const catalystApp = catalyst.initialize(context);
const eventData = JSON.parse(event.getArgument());
console.log('Event received:', eventData);
context.close();
} catch (error) {
console.error('Event processing error:', error);
context.close();
}
};
```
---
## Cron Function Template
```javascript
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = async (cronDetails, context) => {
try {
const catalystApp = catalyst.initialize(context);
context.closeWithSuccess();
} catch (error) {
context.closeWithFailure();
}
};
```
---
## Job Function Template
```javascript
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = async (jobData, context) => {
try {
// catalyst.initialize(context) defaults to Admin scope in non-HTTP functions.
// Passing { scope: 'admin' } is explicit but equivalent to the default.
const catalystApp = catalyst.initialize(context, { scope: 'admin' });
const jobDetails = jobData.getAllJobParams();
const maxMs = context.getMaxExecutionTimeMs(); // 15 minutes
const zcql = catalystApp.zcql();
const rows = await zcql.executeZCQLQuery('SELECT * FROM MyTable LIMIT 0, 300');
context.closeWithSuccess();
} catch (error) {
context.closeWithFailure();
}
};
```
---
## Integration Function Template
```javascript
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = (event, context) => {
try {
const catalystApp = catalyst.initialize(context);
const integrationData = JSON.parse(event.getArgument());
context.close();
} catch (error) {
context.close();
}
};
```
> Integration functions are NOT available in EU, AU, IN, JP, SA, or CA data centers.
---
## SDK Component Reference
```bash
npm install zcatalyst-sdk-node
```
```javascript
const dataStore = catalystApp.datastore();
const table = dataStore.table('TableName');
const inserted = await table.insertRow({ ColumnName: value });
const insertedRows = await table.insertRows([{ ColumnName: value1 }, { ColumnName: value2 }]);
const updated = await table.updateRow({ ROWID: rowId, ColumnName: value });
await table.deleteRow(rowId);
const row = result.TableName; // unwrap ZCQL table wrapper, e.g. result.Orders
const zcql = catalystApp.zcql();
const cache = catalystApp.cache();
const stratus = catalystApp.stratus();
const email = catalystApp.email();
const userManagement = catalystApp.userManagement();
const pushNotification = catalystApp.pushNotification();
const search = catalystApp.search();
const nosql = catalystApp.nosql();
const connection = catalystApp.connection();
```
> Data Store tables must exist before SDK operations target them. Functions cannot create tables programmatically — use Zoho MCP for table creation and schema updates.
---
## Retry Behavior
| Function Type | Auto-retry on failure? |
|---------------|----------------------|
| Basic I/O | No |
| Advanced I/O | No |
| Event | Yes |
| Cron | Yes |
| Job | Yes |
| Integration | No |
| Browser Logic | No |
Design background function handlers to be **idempotent** (safe to run multiple times).
---
## SDK Auth Scope in Job / Cron / Event Functions
### Default Behavior (Official)
`catalyst.initialize(context)` — used in Job, Cron, and Event function boilerplates — **defaults to Admin scope**. From the official docs:
> "It is not mandatory to initialize with a scope. By default, a project that is initialized will have Admin privileges."
Explicitly passing `{ scope: 'admin' }` is equivalent to the default. It is not required but is acceptable for clarity.
**Scope only applies to: DataStore, FileStore, and ZCQL.** Other services (Cache, Circuits, Zia, Push Notifications) always require admin regardless of the scope flag.
### SDK Operation → Required Scope (Official Table)
| DataStore Operation | Scope Required |
|---------------------|----------------|
| Get rows, Update rows, Delete rows, ZCQL query | User **or** Admin |
| Bulk Read, Bulk Write, Bulk Delete | **Admin only** |
| Other Component | Scope Required |
|-----------------|----------------|
| Cache | Admin only |
| Circuits | Admin only |
| Zia Services | Admin only |
| File Store (upload, download, delete) | User or Admin |
| File Store (other operations) | Admin only |
| Email, Search | User or Admin |
| Push Notifications | Admin only |
### When to Use User Scope
User scope is only relevant in **HTTP functions** (Basic I/O / Advanced I/O) where a real user token is present in the request:
```javascript
// HTTP function — user scope applies the caller's Data Store permissions
const userApp = catalyst.initialize(req, { scope: 'user' });
// HTTP function — admin scope bypasses per-user table permissions
const adminApp = catalyst.initialize(req, { scope: 'admin' });
```
In Job/Cron/Event functions there is no user in the request — the first argument is always `context`, not `req`. Passing `{ scope: 'user' }` in a non-HTTP function will fail because there is no user token to resolve.
### Bulk Read for Large DataStore Reads
When a Job function needs to read more than 300 rows, ZCQL pagination inside a 15-minute limit is risky for very large tables. Use the Bulk Read REST API instead:
- Requires Admin scope (confirmed in SDK scope table)
- Triggers an async job; use callback URL or poll the Check Bulk Read Status API
- Returns a CSV download URL on completion
- Can read up to 200,000 records per page
---
| Runtime | Cold start | Warm invocation |
|---------|-----------|-----------------|
| Node.js | 500ms–2s | 50–200ms |
| Java | 2–8s | 50–200ms |
| Python | 500ms–2s | 50–200ms |
**Mitigation:** Keep packages small, avoid heavy initialization outside the handler, use Job Scheduling to ping critical functions warm.
---
## Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| `res.status()` / `res.json()` in node20 | Raw-http template — no Express methods | Use `res.writeHead()` + `res.end()` |
| `req.body` undefined | Raw-http template — no body parser | Manually parse with `getBody()` helper |
| `req.query` undefined | Raw-http template — no query parser | Use `new URL(req.url, ...).searchParams` |
| `basicIO.write()` called twice | Can only be called once per execution | Call `basicIO.write()` exactly once |
| Admin-scope for `getCurrentUser()` | Admin scope has no user token | Use user-scope: `catalyst.initialize(req)` |
| `req.headers['authorization']` undefined | Gateway strips it before function receives it | Use `catalyst.initialize(req)` to identify the user |
| Using `cors()` middleware with Slate | Gateway owns CORS for production origins | Only set CORS headers for `localhost` |
| `new Date(row.CREATEDTIME)` wrong timezone | Stored timestamp lacks timezone offset | Append timezone offset before parsing |
| Inserting emoji into Data Store | Unsupported character in column type | Store a string key instead |
| Not paginating ZCQL | Max 300 rows per query | Use `LIMIT offset, count` |
| `is_deployed: false` in API responses | All functions return this value regardless of live status | Verify deployment status in the Console |
| Need to read >300 rows in a Job function | ZCQL cap is 300 rows; paginating inside 15-min limit is risky | Use the Bulk Read REST API for large-volume reads |
| `busboy` never emits `finish` event | Pipe not set up before response end or `req` not passed correctly | Ensure `req.pipe(bb)` and `finish` listener registered before piping |
| File upload silently truncated | Function memory limit exceeded mid-stream | Use pre-signed Stratus URL for files > 50 MB |
| Chained function call times out | Inner function cold start adds latency beyond outer timeout | Use `invokeType: 'async'` for fire-and-forget; Job functions for long pipelines |
| `Cannot read properties of undefined (reading 'files')` | `express-fileupload` not added as middleware before route | Add `app.use(fileUpload())` before route definitions |
| Python Job: calling `dir(context)` before `initialize()` to "fix" SDK failures | **FALSE.** `dir(context)` has no effect on SDK init — tested on Python 3.13 runtime. Both paths produce identical DataStore/ZCQL access. | If Python Job SDK calls fail, check scope, table permissions, and column names — not `dir()` ordering |
| `table.updateRow()` hangs in Job functions with admin scope | **FALSE.** `table.updateRow()` works reliably in Node.js Job functions with `{ scope: 'admin' }` — tested, completes in ~425ms with no hang | If updates hang, check that ROWID is present in the payload and that admin scope is used; user-scope in a Job function (no user token) will fail |
| Immediate jobs (`job.submitJob()`) have a shorter timeout than scheduled jobs | **FALSE.** Immediate jobs have the same **15-minute timeout** as scheduled Job functions — runtime-confirmed (130s sleep completed successfully) | No special handling needed for immediate vs scheduled jobs |
references/functions-basics.md
> **⚠️ PRE-FLIGHT CHECK (in order):**
>
> **Step 1 — MCP connection (MUST come first).**
> Check that `CatalystbyZoho_*` tools are available. If they are NOT, STOP immediately.
> Do NOT check for `.catalystrc`, do NOT run any CLI commands, do NOT scaffold any files.
> Guide the user to set up Zoho MCP first (see the SKILL.md setup instructions). Resume only after the user confirms MCP tools are visible.
>
> **Step 2 — Project context.**
> Run `CatalystbyZoho_List_All_Organizations` → `CatalystbyZoho_List_All_Projects` to confirm which org ID and project ID you are working with.
>
> **Step 3 — Local scaffold check.**
> Check whether `.catalystrc` exists in the current directory.
> - **If `.catalystrc` exists:** project is initialized — proceed.
> - **If `.catalystrc` does NOT exist:** use org ID and project ID from Step 2:
> ```bash
> catalyst init --org <orgId> -p <projectId> -ni
> ```
> **Never ask the user to run `catalyst init` interactively. Never create `.catalystrc` or `catalyst.json` manually.**
>
> `catalyst init -ni` only creates `.catalystrc`. `catalyst.json` is absent at this point — that is expected.
>
> **Step 3b — Adding functions (non-interactive, CLI v1.27.0+).**
> Run this to add a function — it creates `catalyst.json` on first run:
> ```bash
> catalyst functions:add --name <name> --type <type> --stack <stack> -ni
> ```
> Valid `--type` values: `bio`, `aio`, `event`, `cron`, `job`, `integ`, `browserlogic`
> Valid `--stack` values: `node24`/`node22`/`node20`/`node18`, `java25`/`java21`/`java17`/`java11`/`java8`, `python_3_13`/`python_3_12`/`python_3_11`/`python_3_10`
## `catalyst-config.json` — `type` Field Values
The `type` field is set by the CLI when a function is created. **Do not change it manually** — it determines how Catalyst invokes the function.
| Function Type | `"type"` value |
|---------------|----------------|
| Basic I/O | `"basicio"` |
| Advanced I/O | `"advancedio"` |
| Cron | `"cron"` |
| Job | `"job"` |
| Event | `"event"` |
| Integration | `"integration"` |
| Browser Logic | `"browserlogic"` |
> `"browserlogic"` for Browser Logic — NOT `"browselogic"`. Basic I/O is `"basicio"` — NOT `"basiccron"`.
---
> ⚠️ **Never use `npm install --silent` inside function directories.** The `--silent` flag suppresses `ETARGET` errors (no matching package version), so the install appears to succeed. The missing package only surfaces at runtime as `MODULE_NOT_FOUND` — after deploy, with no clear link to the install step.
Required `catalyst.json` schema for a functions project:
```json
{
"functions": {
"folder_path": "functions",
"targets": ["<function-folder-name>"]
}
}
```
- `folder_path` is the directory containing all function folders.
- `targets` lists each function folder name to be deployed.
- Add every new function folder to `targets` before running deploy.
- `catalyst.json` is project structure metadata; `catalyst-config.json` is per-function runtime/deployment configuration.
### Deployment Command Note
- Use `catalyst deploy --only functions:<function-name>` to deploy one function.
- `functions:<name>` targets a specific function by its folder name.
- Use `catalyst deploy --only functions` to deploy all functions at once.
⚠️ **The URL shown after deploy is missing the `/execute` suffix.** The deploy output shows:
```
FUNCTION URL: https://{project}.catalystserverless.com/server/{function_name}/
```
The actual invocation URL requires `/execute` appended:
```bash
# ❌ Returns 404
curl https://project-xxx.catalystserverless.com/server/my_function/
# ✅ Works
curl https://project-xxx.catalystserverless.com/server/my_function/execute
```
---
## Function Types Overview
| Type | Invocation | Handler Args (Node.js) | SDK Init |
|------|-----------|----------------------|----------|
| Basic I/O | HTTP GET | `(context, basicIO)` | Optional (only if using Catalyst services) |
| Advanced I/O | HTTP any method | `(req, res)` | `catalyst.initialize(req)` |
| Event | Signals/Event Listeners | `(event, context)` | `catalyst.initialize(context)` |
| Cron | Scheduled | `(cronDetails, context)` | `catalyst.initialize(context)` |
| Integration | Zoho service triggers | `(event, context)` | `catalyst.initialize(context)` |
| Job | Job Scheduling | `(jobData, context)` | `catalyst.initialize(context, { scope: 'admin' })` |
| Browser Logic | SmartBrowz | Node.js: `module.exports.puppeteer = async (request, response, page)` — Java: `runner(HttpServletRequest, HttpServletResponse, ChromeDriver driver)` | Pre-initialized (browser injected) |
**Critical:** never copy code between function types. Each type has a different handler signature and initialization pattern. Always start from the correct template.
---
## Execution Limits
| Function Type | Timeout | Behavior |
|---------------|---------|----------|
| Basic I/O | 30 seconds | Returns 504 |
| Advanced I/O | 30 seconds | Returns 504 |
| Event | 15 minutes | Silently terminated |
| Cron | **15 minutes** (900,000ms) | Marked as failed |
| Integration | 30 seconds | Error to calling Zoho service |
| Job | **15 minutes** (900,000ms) | Marked as failed |
| Browser Logic | 30 seconds | Browser instance terminated |
**Runtime-confirmed limits:** Cron and Job functions can query their max execution time via `context.getMaxExecutionTimeMs()` — returns `"900000"` (STRING, not number). Advanced I/O has a 30-second limit but no runtime API to read it.
**Immediate vs scheduled jobs:** Jobs submitted via `job.submitJob()` or the Catalyst API (immediate/instant jobs) have the **same 15-minute timeout** as scheduled Job functions — runtime-confirmed (2min 11s sleep completed successfully).
For tasks exceeding 30s, use Event/Job/Cron (15-min limit).
For tasks exceeding 15 min, use AppSail (no timeout).
---
## Basic I/O Function Template (Node.js)
```javascript
// functions/my_basic_io/index.js
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = (context, basicIO) => {
try {
// catalyst.initialize(context) — optional, only needed if using Catalyst services (DataStore, ZIA, etc.)
const inputData = basicIO.getArgument('input'); // key name matches query param in URL
const result = `Processed: ${inputData}`;
basicIO.write(result); // Can only call write() ONCE
} catch (error) {
console.error('Error:', error);
basicIO.write(JSON.stringify({ error: error.message }));
}
context.close(); // REQUIRED — without this, Catalyst waits until timeout (408)
};
```
Invocation: `GET /server/my_basic_io/execute?input=<value>`
> The query param key (`input` here) must match the string you pass to `basicIO.getArgument()`. There is no special `args` key.
Limitations:
- Returns **STRING only** — use Advanced I/O for JSON responses.
- `basicIO.write()` can only be called **once** per execution.
- Does NOT support HTTP headers or status codes.
---
## Advanced I/O Function Template (Node.js)
> **Raw-http template (default).** `req` is raw `http.IncomingMessage`, `res` is raw `http.ServerResponse`.
> Use `res.writeHead()`/`res.end()` — NOT Express methods like `res.status()` or `res.json()`.
> If you want Express-style API (`req.body`, `res.json()`, middleware), select the **Express template** at function creation time.
```javascript
// functions/my_api/index.js
'use strict';
const catalyst = require('zcatalyst-sdk-node');
function sendJson(res, statusCode, data) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function getBody(req) {
return new Promise((resolve, reject) => {
if (req.body && typeof req.body === 'object') return resolve(req.body);
if (req.body && typeof req.body === 'string') {
try { return resolve(JSON.parse(req.body)); } catch (e) { return resolve({}); }
}
let data = '';
req.on('data', (chunk) => { data += chunk; });
req.on('end', () => {
try { resolve(data ? JSON.parse(data) : {}); } catch (e) { resolve({}); }
});
req.on('error', reject);
});
}
// ⚠️ req.url includes the /execute prefix — strip it before routing.
// A call to .../server/api/execute/users arrives as req.url = '/execute/users'.
// Without the strip, pathname === '/users' never matches and every route returns 404.
module.exports = async (req, res) => {
try {
const catalystApp = catalyst.initialize(req);
const method = req.method;
const parsedUrl = new URL(req.url, `https://${req.headers.host}`);
const path = parsedUrl.pathname.replace(/^\/execute/, ''); // '/execute/users' → '/users'
const query = Object.fromEntries(parsedUrl.searchParams);
if (method === 'GET' && path === '/') {
sendJson(res, 200, { message: 'GET request', id: query.id });
} else if (method === 'POST' && path === '/') {
const body = await getBody(req);
sendJson(res, 201, { message: 'Created', data: body });
} else if (method === 'PUT' && path === '/') {
const body = await getBody(req);
sendJson(res, 200, { message: 'Updated', data: body });
} else if (method === 'DELETE' && path === '/') {
sendJson(res, 200, { message: 'Deleted' });
} else {
sendJson(res, 405, { error: 'Method not allowed' });
}
} catch (error) {
sendJson(res, 500, { error: error.message });
}
};
```
> **Legacy projects (node14/16/18)** use 4-parameter signature:
> `module.exports = (catalystApp, context, req, res) => { ... }`
> New CLI-initialized projects (node20+) use the 2-parameter format shown above.
### User-scope vs admin-scope
```javascript
// USER SCOPE — for resolving user identity only
const userApp = catalyst.initialize(req);
const currentUser = await userApp.userManagement().getCurrentUser();
// Only works for registered app users, NOT collaborators/admins.
// ADMIN SCOPE — for all data operations
const adminApp = catalyst.initialize(req, { scope: 'admin' });
const dataStore = adminApp.datastore();
const zcql = adminApp.zcql();
```
**Never use admin-scope for `getCurrentUser()`** — it throws "no user credentials present".
### CORS for Slate → Function cross-domain
Catalyst provides **two mutually exclusive** ways to handle CORS. Using both at the same time causes duplicate headers and browser rejections.
#### Option 1: Authorized Domains (Recommended for Slate apps)
Console → Authentication → Authorized Domains → add your Slate domain.
> **Authorized Domains applies to AppSail too** — not just Functions. If your frontend calls an AppSail backend, register the Slate domain using `CatalystbyZoho_Create_CORS_Domain` (pass the bare domain, no `https://` prefix) and remove any manual CORS headers from your AppSail Express code. See `catalyst-appsail/references/appsail-crossorigin.md` for the full setup.
⚠️ **When using Authorized Domains, do NOT add any CORS headers in your function code.** Catalyst injects them automatically. Adding headers manually causes:
```
Access-Control-Allow-Origin header contains multiple values
'https://your-app.onslate.com, https://your-app.onslate.com'
```
The browser rejects this with a CORS error even though the origin is correct.
```javascript
// ❌ WRONG — causes duplicate headers when Authorized Domains is active
function sendJson(res, statusCode, data) {
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'https://your-app.onslate.com', // ← remove this
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', // ← remove this
'Access-Control-Allow-Headers': 'Content-Type' // ← remove this
});
res.end(JSON.stringify(data));
}
// ✅ CORRECT — let Catalyst handle CORS, only set Content-Type
function sendJson(res, statusCode, data) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
```
**Why this happens:** Authorized Domains injects `Access-Control-Allow-Origin` at the gateway level. When your function also sets it, the response carries two identical header values — which is invalid per the CORS spec and rejected by all browsers.
#### Option 2: Manual CORS headers (for localhost dev or non-Slate consumers)
Only use this when NOT using Authorized Domains. For `localhost` development:
```javascript
// Raw-http template — add directly in your handler:
module.exports = async (req, res) => {
const origin = req.headers.origin || '';
if (/^http:\/\/localhost(:\d+)?$/.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
}
// ... rest of your handler
};
```
> For Express template, use `app.use((req, res, next) => { ... next(); })` — see `functions-advanced.md`.
### HTTP payload limits
- Request body: **250 MB**
- Response body: **250 MB**
---
## Security Rules
- **`optional`** — Anyone can invoke the function (public access). This is the default.
- **`required`** — Only authenticated users can invoke.
⚠️ Values like `no_auth`, `user_auth`, `admin_auth` do NOT exist.
Security Rules are binary (public vs authenticated). For admin-only or per-route control, use API Gateway instead.
---
## Function Timeout Troubleshooting
**Symptom:** Function doesn't respond, `curl` hangs or returns status `000`, no output in logs.
**Common causes:**
1. Infinite loop — e.g., `for await` loop missing opening `{` brace
2. SDK initialization hanging — verify `CATALYST_PROJECT_ID` env is set or `catalyst.json` exists
3. Unhandled promise rejection with no `catch` block
4. Database query with no timeout that never resolves
5. Missing `return` or `response.end()` in one or more code paths
**Debug pattern — add checkpoints and always catch errors:**
```javascript
module.exports = async (req, res) => {
console.log('Function started');
try {
console.log('Initializing SDK');
const app = catalyst.initialize(req);
console.log('SDK initialized');
// your logic here
console.log('Sending response');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
console.error('ERROR:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
};
```
Check Catalyst Console → Functions → Logs after each deploy to see which `console.log` was the last to fire.
---
## Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| `Access-Control-Allow-Origin header contains multiple values` | Authorized Domains active + manual CORS headers in function code | Remove all CORS headers from function code — Catalyst adds them automatically via Authorized Domains |
| Function returns 404 after deploy | Using URL without `/execute` suffix | Append `/execute` to the URL shown in deploy output |
| Function hangs / status `000` | Infinite loop, missing `response.end()`, or unhandled promise | Add `console.log` checkpoints; check Catalyst logs; verify all code paths call `res.end()` |
| `catalyst.initialize is not a function` | Wrong import or wrong function type template | Ensure `const catalyst = require('zcatalyst-sdk-node')` and use `catalyst.initialize(req)` in Advanced I/O |
| `context.close is not a function` | Used Advanced I/O template code in a Basic I/O function | Basic I/O uses `context.close()`, not `res.end()` — do not mix templates |
references/functions-templates.md
# Function Templates — Event, Cron, Job, Integration
Handler templates for background and scheduled function types. Load this file when the query is about Event, Cron, Job, or Integration functions — NOT Basic I/O or Advanced I/O (those are in `functions-basics.md`).
## `catalyst-config.json` — `type` Field Values
The `type` field is set by the CLI when a function is created. **Do not change it manually.**
| Function Type | `"type"` value |
|---------------|----------------|
| Basic I/O | `"basicio"` |
| Advanced I/O | `"advancedio"` |
| Cron | `"cron"` |
| Job | `"job"` |
| Event | `"event"` |
| Integration | `"integration"` |
| Browser Logic | `"browserlogic"` |
> `"browserlogic"` for Browser Logic — NOT `"browselogic"`. Basic I/O is `"basicio"` — NOT `"basiccron"`.
---
## Event Function Template
Triggered by Catalyst Signals or Event Listeners.
```javascript
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = (event, context) => {
try {
const catalystApp = catalyst.initialize(context);
const eventData = JSON.parse(event.getArgument());
console.log('Event received:', eventData);
context.close();
} catch (error) {
console.error('Event processing error:', error);
context.close();
}
};
```
---
## Cron Function Template
Triggered on a schedule. Always call `closeWithSuccess()` or `closeWithFailure()` — never leave the context open.
```javascript
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = async (cronDetails, context) => {
try {
const catalystApp = catalyst.initialize(context);
const maxMs = context.getMaxExecutionTimeMs(); // "900000" (STRING) = 15 minutes
// Your scheduled task logic here
context.closeWithSuccess();
} catch (error) {
context.closeWithFailure();
}
};
```
---
## Job Function Template
Triggered via Job Scheduling. Use `{ scope: 'admin' }` for system-level DataStore/ZCQL operations that don't need a specific user context.
```javascript
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = async (jobData, context) => {
try {
const catalystApp = catalyst.initialize(context, { scope: 'admin' });
const jobDetails = jobData.getAllJobParams();
const maxMs = context.getMaxExecutionTimeMs(); // "900000" (STRING) = 15 minutes
const zcql = catalystApp.zcql();
const rows = await zcql.executeZCQLQuery('SELECT * FROM MyTable LIMIT 0, 300');
context.closeWithSuccess();
} catch (error) {
context.closeWithFailure();
}
};
```
> **Using Zoho MCP to submit a job?** Always call `CatalystbyZoho_List_All_Jobpools` before `CatalystbyZoho_Create_Immediate_Job`. `jobpool_id` is required — there is no default. If no pools exist, call `CatalystbyZoho_Create_Job_Pool` (type `"Function"`, memory e.g. `"256"`) first.
### Python Job Function Template
```python
import logging
import zcatalyst_sdk
def handler(job_request, context):
logger = logging.getLogger()
try:
# catalyst.initialize(context) defaults to Admin scope in non-HTTP functions
app = zcatalyst_sdk.initialize(req=context)
max_ms = context.get_max_execution_time_ms() # "900000" (STRING) = 15 minutes
remaining_ms = context.get_remaining_execution_time_ms() # decrements as function runs
all_params = job_request.get_all_job_params()
job_details = job_request.get_job_details()
zcql = app.zcql()
rows = zcql.execute_query('SELECT * FROM MyTable LIMIT 0, 300')
logger.info(f'Fetched {len(rows)} rows')
context.close_with_success()
except Exception as e:
logger.error(f'Job failed: {e}')
context.close_with_failure()
```
**Python context API (Job and Cron):**
- `context.get_max_execution_time_ms()` — returns `"900000"` (STRING, 15 min)
- `context.get_remaining_execution_time_ms()` — decrements live
- `context.close_with_success()` — mark job succeeded
- `context.close_with_failure()` — mark job failed
### Local execute vs deployed runtime
`catalyst functions:execute` proves your handler logic — it does NOT prove deployed environment variables, scheduled timing, or Job pool behavior. After local smoke tests, verify long-running Job logic with a real remote/scheduled run before declaring it working.
If edited source doesn't appear in `functions:execute` output, delete the stale build cache: `rm -rf functions/<name>/.build` and re-run.
Note: Job **pool** memory and **function** memory are separate settings — raising the pool size alone does not raise the function's execution memory. The job function's allocated memory must be **less than** the job pool's allocated memory, or jobs suffer dispatch delays.
---
## Integration Function Template
Triggered by events from other Zoho services (e.g., Zoho CRM, Zoho Books).
```javascript
'use strict';
const catalyst = require('zcatalyst-sdk-node');
module.exports = (event, context) => {
try {
const catalystApp = catalyst.initialize(context);
const integrationData = JSON.parse(event.getArgument());
context.close();
} catch (error) {
context.close();
}
};
```
> Integration functions are NOT available in EU, AU, IN, JP, SA, or CA data centers.
---
## SDK Component Reference
```bash
npm install zcatalyst-sdk-node
```
```javascript
const dataStore = catalystApp.datastore();
const table = dataStore.table('TableName');
const inserted = await table.insertRow({ ColumnName: value });
const insertedRows = await table.insertRows([{ ColumnName: value1 }, { ColumnName: value2 }]);
const updated = await table.updateRow({ ROWID: rowId, ColumnName: value });
await table.deleteRow(rowId);
const row = result.TableName; // unwrap ZCQL table wrapper, e.g. result.Orders
const zcql = catalystApp.zcql();
const cache = catalystApp.cache();
const stratus = catalystApp.stratus();
const email = catalystApp.email();
const userManagement = catalystApp.userManagement();
const pushNotification = catalystApp.pushNotification();
const search = catalystApp.search();
const nosql = catalystApp.nosql();
const connection = catalystApp.connection();
```
> Data Store tables must exist before SDK operations target them. Functions cannot create tables programmatically — use Zoho MCP for table creation and schema updates.
---
## Retry Behavior
| Function Type | Auto-retry on failure? | Notes |
|---------------|----------------------|-------|
| Basic I/O | No | |
| Advanced I/O | No | |
| Event | Yes | Platform retries automatically |
| Cron | **No** | Failures trigger Application Alerts only — no automatic retry. Manual review and rerun required. |
| Job | Configurable | Retry is set in `job_config.number_of_retries` when submitting the job (0–10 retries, min 1-min interval) |
| Integration | No | |
| Browser Logic | No | |
> **Cron failure handling**: Configure Application Alerts (Console → Cloud Scale → Cron → Alerts) to receive email notifications when a cron fails, times out, or throws an exception. Review execution history from the console to debug and rerun.
>
> **50-consecutive-failures auto-disable** applies ONLY to crons associated with a **third-party URL** target — NOT to function-based crons. Function-based crons never auto-disable regardless of repeated failures.
Design **Event** and **Job** handlers to be **idempotent** (safe to run multiple times). Cron handlers should also be idempotent to safely support manual reruns.
---
## Cold Starts
| Runtime | Cold start | Warm invocation |
|---------|-----------|-----------------|
| Node.js | 500ms–2s | 50–200ms |
| Java | 2–8s | 50–200ms |
| Python | 500ms–2s | 50–200ms |
**Mitigation:** Keep packages small, avoid heavy initialization outside the handler, use Job Scheduling to ping critical functions warm.
---
## Common Errors
| Error | Cause | Fix |
|-------|-------|-----|
| `res.status()` / `res.json()` in node20 | Raw-http template — no Express methods | Use `res.writeHead()` + `res.end()` |
| `req.body` undefined | Raw-http template — no body parser | Manually parse with `getBody()` helper |
| `req.query` undefined | Raw-http template — no query parser | Use `new URL(req.url, ...).searchParams` |
| `basicIO.write()` called twice | Can only be called once per execution | Call `basicIO.write()` exactly once |
| Admin-scope for `getCurrentUser()` | Admin scope has no user token | Use user-scope: `catalyst.initialize(req)` |
| `req.headers['authorization']` undefined | Gateway strips it before function receives it | Use `catalyst.initialize(req)` to identify the user |
| Using `cors()` middleware with Slate | Gateway owns CORS for production origins | Only set CORS headers for `localhost` |
| `new Date(row.CREATEDTIME)` wrong timezone | Stored timestamp lacks timezone offset | Append timezone offset before parsing |
| Inserting emoji into Data Store | Unsupported character in column type | Store a string key instead |
| Not paginating ZCQL | Max 300 rows per query | Use `LIMIT offset, count` |
| `is_deployed: false` in API responses | All functions return this value regardless of live status | Verify deployment status in the Console |
| Need to read >300 rows in a Job function | ZCQL cap is 300 rows; paginating inside 15-min limit is risky | Use the Bulk Read REST API for large-volume reads |
| `INVALID_INPUT: job_name must contain only alphanumeric and underscore` | `job_name` contains hyphens or spaces | Use underscores only — `doc_audit_run_1` not `doc-audit-run-1` |
| `busboy` never emits `finish` event | Pipe not set up before response end | Ensure `req.pipe(bb)` and `finish` listener registered before piping |
| File upload silently truncated | Function memory limit exceeded mid-stream | Use pre-signed Stratus URL for files > 50 MB |
| Chained function call times out | Inner function cold start exceeds outer timeout | Use `invokeType: 'async'` for fire-and-forget; Job functions for long pipelines |
| `Cannot read properties of undefined (reading 'files')` | `express-fileupload` not added as middleware before route | Add `app.use(fileUpload())` before route definitions |
| Timeout math fails in Cron/Job | `context.getMaxExecutionTimeMs()` returns STRING | Use `parseInt(context.getMaxExecutionTimeMs())` for calculations |
SKILL.md
---
name: catalyst-functions
description: "Catalyst serverless functions — all 7 types (Basic I/O, Advanced I/O, Event, Cron, Job, Integration, Browser Logic), handler signatures, catalyst-config.json, Security Rules, API Gateway routing, file uploads, busboy, Express middleware, environment variables, function URL, and function testing. Requires MCP connection — check for CatalystbyZoho_* tools before any operation. Trigger on 'write a function', 'catalyst function', 'API Gateway', 'Security Rules', 'function not found', 'function returns 401', 'busboy', 'middleware', 'function URL', 'environment variable in function', 'duplicate CORS headers', 'CORS error in browser', 'Access-Control-Allow-Origin multiple values', 'function URL 404', 'execute suffix', 'function timeout', 'function hangs', or any function type question. Do NOT use for persistent servers, long-running processes, or Docker deployments — use catalyst-appsail instead."
compatibility: "Requires Catalyst CLI (`npm install -g zcatalyst-cli`) and Node.js v20 (recommended; v14–v18 also supported). Java functions also require JDK 8, 11, or 17. Python functions require Python 3.9."
metadata:
version: "2.0.1"
---
## How It Works
**Intent check — do this first:**
- If the user is asking a how-to or conceptual question ("how do I write a function", "show me a Basic I/O handler", "how does Security Rules work"), answer directly with the correct code or explanation. Do NOT inspect the working directory, do NOT generate a CLAUDE.md, do NOT switch into codebase-analysis mode. Empty directory = fine for how-to questions.
- Only inspect the filesystem when the user explicitly asks to scaffold, add, or deploy something in their project.
1. **Verify local scaffold (only when scaffolding, not for how-to questions) — both `catalyst init` and `functions:add` support non-interactive mode (CLI v1.27.0+).**
Check whether `.catalystrc` exists. If missing, use MCP tools to get the org ID and project ID, then run:
```bash
catalyst init --org <orgId> -p <projectId> -ni
```
Never ask the user to run `catalyst init` interactively. NI mode can only link an existing project — if none exists, tell the user to create one in the console first. `catalyst.json` does not exist yet after `init -ni` — that is expected. Add functions next (this creates `catalyst.json`):
```bash
catalyst functions:add --name <name> --type <type> --stack <stack> -ni
# e.g. catalyst functions:add --name api --type aio --stack node20 -ni
```
2. **Identify the function type** — Basic I/O for simple request/response, Advanced I/O for raw HTTP control, Event for trigger-based, Cron/Job for scheduled, Integration for Zoho service events, Browser Logic for Puppeteer.
3. **Load `references/functions-basics.md`** — for the matching handler signature, `catalyst-config.json` keys, SDK init pattern, and CORS setup.
4. **Load `references/functions-advanced.md`** — for file uploads (busboy), streaming responses, error handling, or chaining functions.
5. **Load `references/api-gateway.md`** — for routing rules, rate limiting, or gateway-level CORS.
6. **Validate config** — Confirm `catalyst-config.json` uses `deployment` + `execution` keys only. Never use `function` or `entry_point`.
## Response Syntax Default
**Always default to native Node.js response syntax.** Advanced I/O exposes raw `http.ServerResponse` — Express methods (`res.status()`, `res.json()`) do not exist unless the user explicitly chose the Express template.
- Native (default): `res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data));`
- Express (opt-in only): `res.status(200).json(data)` — only if the user has `express` installed and wired as middleware
If you don't know which template the user has, ask or default to native.
## Security Checklist
- **Functions are publicly accessible by default.** Security Rules sets `authentication` to `optional` when a function is created — its URL is globally accessible to everyone with no restrictions. Set `"authentication": "required"` in the Security Rules JSON for any function that reads or writes user data or sensitive resources.
- **API Gateway replaces Security Rules — do not use both.** Enabling API Gateway automatically disables Security Rules. Pick one auth/routing layer per function.
## Triggers
Use this skill for: "write a function", "catalyst function", "Basic I/O", "Advanced I/O", "Event function", "Cron function", "Browser Logic", `catalyst-config.json`, "function handler", "API Gateway", "rate limiting", "busboy", "file upload in function", `catalyst deploy --only functions:<function-name>`, `catalyst functions:add`, "function CORS", or any function type or function configuration question.
Deployment command note:
- Use `catalyst deploy --only functions:<function-name>` to deploy one function (where `functions:<name>` targets the function by its folder name).
- Use `catalyst deploy --only functions` to deploy all functions at once.
## References
| Reference | Load when the query is about… |
|-----------|-------------------------------|
| `references/functions-basics.md` | **Start here for any function question.** Function type selection, Basic I/O and Advanced I/O handler signatures, `catalyst-config.json`, user-scope vs admin-scope, CORS, Security Rules, execution limits |
| `references/functions-advanced.md` | **Advanced I/O patterns only.** Express vs raw-http template differences, file uploads (busboy), streaming files from Stratus, error handling patterns, CORS for local dev, local testing, function chaining, ZCQL result unwrapping, HTTP payload limits |
| `references/functions-templates.md` | **Event, Cron, Job, or Integration functions.** Handler templates for all background/scheduled types, SDK component reference, retry behavior, cold start data, and the full common errors table |
| `references/api-gateway.md` | **API Gateway config only.** Enable/disable gateway, routing rules, rate limiting, CORS via gateway |