Node.js functions
Node.js is the only runtime where an async handler works. This page is the complete guide — scaffold, deploy, every trigger type, secrets, invoking, logs, updating, calling other services, and the bugs that actually bite.
The contract
- File:
handler.js, exportinghandle. Bothmodule.exports = { handle }andmodule.exports = fnare accepted. - Async is supported. The shim awaits your return value, so
async function handle(event)and a plainfunction handle(event)both work. This is the only runtime where that is true. - Event: one argument, an object.
GETproduces{}; aPOST's JSON body becomes the object. - Return: an object. The whole thing becomes the JSON response body. Include
statusCodeto set the HTTP status (default200). - Dependencies: an optional
package.jsonnext tohandler.js. npm installs the production dependencies at build time, into the handler's own directory.
Scaffold
my-function/
├── handler.js
└── package.json # optional
The smallest complete function, from the Node.js hello-world example under examples/functions in the examples repository:
'use strict';
function handle(event) {
const name = (event && event.name) || 'world';
return { statusCode: 200, body: `hello, ${name}, from a Node.js function` };
}
module.exports = { handle };
An async version is exactly as valid:
exports.handle = async function (event) {
const res = await fetch('https://example.com');
return { statusCode: 200, upstream: res.status };
};
Deploy
- platformctl
- curl
- Console
The runtime is auto-detected from handler.js:
platformctl functions deploy ./my-function --name my-function
You should see the upload line name the detected runtime — read it before the build starts:
packaging ./my-function...
uploading my-function (2.4 KiB, framework=function, runtime=nodejs)...
The line names the function, not the directory — it is the --name you passed.
tar -czf my-function.tar.gz -C my-function .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer ***" \
-F "name=my-function" \
-F "framework=function" \
-F "runtime=nodejs" \
-F "code=@my-function.tar.gz"
You should see (HTTP 202 — the build runs in the background):
{"agent": "my-function", "build_id": "2f6f2f6e-8a1e-4c3b-9d2a-1b2c3d4e5f6a"}
Then run platformctl status my-function until its state reads ready.
Compute → Functions → Deploy function, then set Language to Node.js. The picker renames the entry file to handler.js and offers a package.json tab. Language is only asked for a new function — an existing one keeps the runtime it was deployed with, carried on its framework token, so redeploying never changes language.
Source with no top-level handler.js is refused before the upload, with a message naming the file the Node.js shim loads. The content check behind it is thin for this runtime — it only looks for the word handle anywhere in the file — so a broken export line still reaches the build and crash-loops there. See common bugs.
The CLI uploads your entire directory (cap 100 MiB) with no ignore file. A node_modules/ folder next to your handler is uploaded as-is — and production deps are installed at build anyway, so node_modules is pure weight. Deploy from a clean directory. The console is stricter: text files only, 1 MiB per file, 16 MiB of source in one deploy, or 32 MiB for a ready-made .tar.gz.
Per-trigger-type handlers
HTTP
function handle(event) {
if (!event || !event.user_id) {
return { statusCode: 400, error: 'user_id is required' };
}
return { statusCode: 200, user: event.user_id };
}
module.exports = { handle };
Scheduled (cron)
A scheduled trigger sends your payload as a CloudEvent; the shim ACKs 204 and discards the return value. console.log is how you observe it:
function handle(event) {
const job = (event && event.job) || 'default';
console.log(`scheduled run: ${job}`);
// ... do the work ...
return { statusCode: 200, ok: true }; // discarded; keep it for direct HTTP calls
}
module.exports = { handle };
Pub/Sub
Each message pushed to the topic arrives unwrapped — your handler sees the message's JSON data:
function handle(event) {
console.log(`processing order ${event && event.order_id}`);
// ... process ...
return { statusCode: 200 };
}
module.exports = { handle };
ObjectStore (bucket events)
An object-store trigger delivers the object's own bytes as the request body. There is no JSON description of the event — no bucket, no key, no size — and no need for an S3 client, because the content already arrived.
The Node.js shim parses that body as JSON. A body that is not JSON is answered 400 {"error": "invalid JSON body: ..."} before the handler is ever called — and the bucket poller reads no retry block at all, so it delivers once: that object is gone. Nothing appears in your logs, because your code did not run. Use this runtime for buckets of JSON objects. For markdown, CSV or binary objects use the Python runtime, whose shim hands a non-JSON body to the handler as event.data.
The key is not delivered either, so identity has to come from inside the object. And the Node.js shim passes no CloudEvent attributes to the handler at all — event._cloudevent, where the Python shim puts the ce-* headers, does not exist here — so there is no second place to look:
function handle(event) {
// The body IS the object, so this only works for .json objects.
console.log(`ingested record ${event && event.id}`);
// ... process ...
return { statusCode: 200 };
}
module.exports = { handle };
The poller is not instant: poll_seconds defaults to 60, so a dropped object can take a full minute to reach the handler. A quiet minute after an upload is normal, not a broken trigger.
Environment and secrets
Both arrive as environment variables; read them with process.env. Env vars can be read back through the platform; secrets never can — only their key names.
- platformctl
- curl
- Console
platformctl agents env set my-function LOG_LEVEL=debug
platformctl secrets set my-function EXTERNAL_API_KEY=abc123
curl -s -X PATCH "$CAI_API/v1/agents/my-function/secrets" \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{"set": {"EXTERNAL_API_KEY": "abc123"}}'
On the function's page, Secrets and environment. Saving rolls a new revision.
Read at module level — env is fixed per revision, so a top-level read does the lookup once:
const API_KEY = process.env.EXTERNAL_API_KEY;
function handle(event) {
if (!API_KEY) return { statusCode: 500, error: 'EXTERNAL_API_KEY is not configured' };
// ...
}
Key names must match ^[A-Za-z_][A-Za-z0-9_]*$. Full details: secrets and environment variables.
Project secrets: bind, then apply
A project secret is stored once and bound into many workloads, each under the variable name its own code expects. Recording the binding delivers nothing — it is a statement of intent — and only apply reads the values, writes them onto the function, and rolls a revision:
printf %s "$STRIPE_KEY" | platformctl secrets put stripe-key
platformctl secrets bindings set my-function STRIPE_KEY --secret stripe-key
platformctl secrets bindings apply my-function # <- this is what delivers it
In the console: Bind a secret on the function's page, then confirm the Apply bindings to my-function? dialog with its Apply to my-function button.
The ordering matters. A deploy rolls its revision immediately, before any binding is applied, so a function deployed first and bound second has a first revision with no bound values on it. const KEY = process.env.STRIPE_KEY is undefined there, which in Node.js is quiet — the module loads, the handler runs, and the failure surfaces downstream as a 401 from whatever you called. Apply after the deploy (the apply rolls a fresh revision), or record the bindings before the first deploy, which picks them up. Guard the value at module scope, as the example above does, so a missing binding names itself.
Invoke pattern
platformctl invoke my-function "some text"
The shared invoke path delivers your text as event.message and drops any other key — only session_id, user_id, message, and memorize survive. The response envelope reports an agent's output field, which a function return has no use for, so you see a blank line and a session id. For arbitrary bodies, call the function's own public_url directly — the full body reaches the handler untouched.
Invoke/stream (NDJSON)
POST /v1/agents/{name}/invoke/stream answers application/x-ndjson: one JSON object per line. For a function the stream is typically one terminal event, but the contract matches the agent one and client code can be shared:
curl -sN -X POST "$CAI_API/v1/agents/my-function/invoke/stream" \
-H 'Content-Type: application/json' \
-d '{"message": "some text"}'
// client-side, one event per line
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'hi' }) });
for await (const line of res.body.pipeThrough(new TextDecoderStream())) {
const trimmed = line.trim();
if (trimmed) console.log(JSON.parse(trimmed));
}
Logs and status
platformctl status my-function # building -> deploying -> ready
platformctl logs my-function # live only; empty at scale-to-zero
platformctl logs my-function --history # persisted lines
console.log lines land in logs; uncaught exceptions land there with a stack trace even when the caller only got {"error": "handler raised: ..."}. Never combine --follow and --history — the CLI rejects it as mutually exclusive.
Update (ship a new version)
platformctl functions deploy ./my-function --name my-function
Each deploy replaces the whole source tree and rebuilds in the same runtime — the recorded framework token keeps the language. In the console, use Update source on the function's page.
Calling other services
Node.js 18+ ships fetch natively, so most outbound calls need no dependency. Credentials belong in secrets — read them from process.env.
The platform injects the addresses of its own services, so a handler never hardcodes one. What a live function is handed:
| Variable | What it addresses |
|---|---|
CAI_API_URL | the platform API — agents, functions, secrets |
CAI_PUBSUB_URL | Pub/Sub — publish to a topic |
CAI_VECTORDB_URL | VectorDB — write and query an index |
EMBED_BASE_URL | the embedding endpoint, OpenAI-compatible, path ends in /v1 |
EMBED_MODEL | qwen-embedding |
Each URL is a private address on the platform's own network — it resolves from inside your project and nowhere else, and your laptop cannot reach it. Its exact form is ours to change, so read it from process.env at every start rather than copying a value into your code. CAI_PROJECT_ID, CAI_PROJECT_KEY, MCP_SERVERS and CRUSOE_REQUEST_TIMEOUT_SECONDS arrive with them, and only MODEL_BASE_URL, CHAT_MODEL, EMBED_BASE_URL and EMBED_MODEL may be overridden by your own env; setting any of the rest is refused by the env write with the reason in the response.
Memory Store: a function dials its project's own instance directly, at connection.host on port 6379 — the platform writes an egress rule for exactly that peer and that port, so a plain redis:// connect from inside the project works. The TLS (rediss://) endpoint is for reaching the instance from outside the platform — from your laptop, say; a function is already inside. Details and the credential Secret: connect from workloads. The Node.js Redis client is the ioredis npm package — declare it as the one dependency in package.json, then:
const RedisClient = require('ioredis');
let client;
function getClient() {
if (!client) {
// MS_HOST is connection.host, MS_PASSWORD the value from ms-<name>-credential,
// both set on this function's env and secrets.
client = new RedisClient(`redis://:${process.env.MS_PASSWORD}@${process.env.MS_HOST}:6379`);
}
return client;
}
Connection caching at module scope matters: a warm instance reuses it, a cold one pays it once.
VectorDB: POST to ${process.env.CAI_VECTORDB_URL}/v1/projects/${process.env.CAI_PROJECT_ID}/indexes/docs:query with { vector, top_k }; the hits come back under results, not points. fetch is native, so this needs no dependency. The query vector's length must match the index exactly; create the index without naming a width and it is sized for qwen-embedding automatically. See the Python guide for the worked request and VectorDB for the index.
Pub/Sub (publish): POST to ${process.env.CAI_PUBSUB_URL}/v1/projects/${process.env.CAI_PROJECT_ID}/topics/${topic}:publish with { messages: [{ text: '...' }] }. Use a service account key, stored as a project secret and bound to the function, as the Authorization: Bearer credential. The injected CAI_PROJECT_KEY will not do: a workload key holds no authority on project routes, and an inaccessible project answers 404 rather than 403, so you get 404 not found on a topic you can see in the console with nothing blaming the credential. Its one power is minting a short-lived token to read this project's secrets.
Firewall note: workloads reach each other only on the platform's own ports (8080, 8012, 8022, 9090, 9091), plus 6379 to the project's own memory store — see workload networking.
Common bugs
Returning a promise without awaiting is fine here — that's the point. The shim awaits the return value. Where callers get burned is the reverse assumption: writing async def handle in Python because it worked in Node.js. In Python it returns a coroutine and the caller gets an empty reply.
exports.handle vs module.exports = { handle }. Both work. Mixing them up — exports = { handle } — silently exports nothing, and every call fails at the shim's load. If every request errors identically from the first call, check the export line first.
require of an undeclared dependency. Production install runs at build; anything not in package.json's dependencies is absent at runtime, failing the module load (not the request) with Cannot find module. devDependencies are not installed.
Stray handler file changes the detected runtime. A leftover handler.py is harmless; a leftover handler.js in a directory you meant to deploy as Python flips the detection to Node.js. Read the upload line — it names the runtime picked.
Over 8 MiB bodies are rejected with 413 before the handler runs. Send a reference instead of the data.
Throwing synchronously vs. rejecting. Both are caught and turned into 500 (400 for a CloudEvent), matching a handler raise. But a rejected promise you never awaited may only surface as an unhandled-rejection warning in logs while the response still succeeds — always await your own promises.