Runtimes
A runtime is the language environment your function runs in. This page gives you the exact contract for each of the four runtimes: the file name, the function name, and how you add libraries. Each one comes with a complete example you can deploy as written.
The shared contract
All four runtimes take the same shape. Each runs behind the same small web server, which the platform provides and which is called the shim. The shim's contract is the same in every language:
| Runtime | Handler file | Handler symbol | Dependencies |
|---|---|---|---|
python (default) | handler.py | def handle(event) | optional requirements.txt |
nodejs | handler.js | exports.handle | optional package.json |
go | handler.go | func Handle | none — standard library only |
ruby | handler.rb | def handle | optional Gemfile |
In all four languages:
- Your handler receives one argument, the event. It arrives as whatever your language calls a key-value collection: a dictionary, hash, object, or map. A
GETrequest produces an empty{}event. APOSTwhose body is a JSON object becomes the event. - Your handler returns a collection of the same kind. The platform turns it into the JSON response body.
- Libraries you depend on are installed once, when you deploy and the image is built. They are not installed on each request.
One thing is not shared: whether your handler may be async. It has its own section below.
Synchronous or asynchronous?
This is the one place the four runtimes genuinely differ.
| Runtime | Async handler |
|---|---|
python | Yes. The shim awaits what your handler returns, so async def handle and a plain def handle both work. |
nodejs | Yes. The shim awaits the return value, so async function handle and a plain function handle both work. |
go | No. The signature is synchronous; run your own goroutines and wait for them inside Handle. |
ruby | No. Your handle method is called directly. |
In Python and Node.js, write the handler whichever way suits the work. An async def is worth it when one request makes several calls that can overlap:
import asyncio
async def handle(event: dict) -> dict:
# Your own coroutine — an async HTTP client from requirements.txt, say.
results = await asyncio.gather(*[fetch(url) for url in event["urls"]])
return {"statusCode": 200, "results": results}
The shim gives each request its own thread and its own event loop, runs your coroutine to completion there, and returns the value your coroutine produced — the same value a plain def would have returned, handled the same way. An async def handle that raises is reported exactly like a plain def that raises: 500 with a handler raised: body for an HTTP call, 400 for a CloudEvent delivery, and the traceback in platformctl logs.
async does not buy you concurrency between requests, and it was never needed for it. Each request already has its own thread, so a slow handler does not hold up anyone else's. What async buys is concurrency within one request: ten calls at once instead of ten in sequence. Your request is still not answered until your coroutine returns, and work that blocks the loop rather than awaiting — time.sleep(30), a synchronous database driver — still holds that one request for as long as it blocks.
defUntil September 2026 the Python shim called your handler without awaiting it, so an async def handle was dropped on the floor: no HTTP response at all for an HTTP call, and a 204 acknowledging a CloudEvent delivery whose body never ran. The shim awaits now. Node.js always did. The shim is baked into your function's image when it is built, so a function deployed before the fix still carries the old one — redeploy it and an async handler works.
The workaround this page used to recommend — a plain def that runs its own loop with asyncio.run — is still correct and still supported, so a function already written that way needs no change:
import asyncio
async def fetch_all(urls):
...
def handle(event: dict) -> dict:
results = asyncio.run(fetch_all(event.get("urls", [])))
return {"statusCode": 200, "results": results}
What you cannot do is both at once. Inside an async def handle you are already on a running loop, and asyncio.run there raises RuntimeError: asyncio.run() cannot be called from a running event loop. await the coroutine instead.
platformctl functions deploy <dir> deploys any of the four. It detects the runtime from the handler file your folder ships: handler.py, handler.js, handler.go, or handler.rb. Use --runtime python|nodejs|go|ruby to name it explicitly instead.
The CLI sends framework=function plus the language in a separate runtime field, which is exactly what POST /v1/agents expects. The server then folds the two into its internal function-<lang> build token.
Until August 2026 the CLI sent that internal token as the framework instead, so every non-Python function deploy failed with unsupported 'framework'. The workaround was to deploy Node.js, Go, and Ruby through the raw API, as the sections below show. That still works. It is no longer necessary.
A body that is not a JSON object
An object-store trigger walks straight into this. A bucket delivery hands your function the object's own bytes as the request body: a markdown file arrives as markdown, a CSV as a CSV, a log line as a log line. All four shims turn that into an event the same way, so the runtime you choose does not decide which objects your function can process:
POST body | What your handler receives |
|---|---|
| A JSON object | The object itself, as the event. |
| Text that is not JSON | {"data": "<text>"} |
| Bytes that are not valid UTF-8 | {"data_base64": "<base64>"} |
| A JSON array or scalar | {"data": [1, 2]}, {"data": 42} — wrapped, so you still get a collection. |
| Nothing at all (an empty body) | {}, plus the CloudEvent attributes below when the delivery carries any. |
Your handler always receives a key-value collection, and no body shape is rejected before your code runs. Use the two-value or defaulted form when you read a key (event.get("data"), event["data"].(string) with its ok): a bucket of mixed objects reaches one handler as text for some drops and as the parsed object for others.
CloudEvent attributes: _cloudevent
Every shim also attaches the delivery's CloudEvent attributes to the event, under the key _cloudevent: every ce-* request header with the prefix stripped, or in structured mode every key of the envelope other than data and data_base64.
def handle(event: dict) -> dict:
ce = event.get("_cloudevent", {})
body = event.get("data", "")
print(ce.get("type"), ce.get("source"), len(body))
return {"statusCode": 200}
Three things to know before you rely on it:
- A plain HTTP request carries no
ce-headers, so the key is simply absent. Ask for it with a default, as above, rather than indexing it. - A payload that already has its own
_cloudeventkey keeps its own value. The shim will not overwrite caller data with platform metadata. - Which attributes arrive depends entirely on the source, and they are not a second copy of the payload. An object-store trigger, in particular, does not put the object's key in them — see Object store triggers.
- A delivery with an empty body still carries them. That is the one case where the attributes are the entire event, so the shim attaches them rather than handing your handler a bare
{}.
The key is spelled _cloudevent in all four runtimes: a handler must not have to know which language it was written in to find the same metadata.
Request headers: _headers
Every shim also attaches the inbound request's headers, under the key _headers. Where _cloudevent says what the event was, this says who sent it and how — and it is there on a plain GET, a plain POST and a CloudEvent delivery alike.
def handle(event: dict) -> dict:
headers = event.get("_headers", {})
print(headers.get("user-agent"))
return {"statusCode": 200, "tenant": headers.get("x-tenant-id")}
Four things to know before you rely on it:
- The names are lowercased, in all four runtimes. Read
_headers["content-type"], never["Content-Type"]. The runtimes disagree about header case and always will, so lower case is the one spelling that works everywhere. - A repeated header is joined with
", ". A map cannot hold two entries under one name, and keeping only the first would lose half of anAcceptchain. - A payload that already has its own
_headerskey keeps its own value, exactly as with_cloudevent. x-cai-internalarrives as[redacted]. It is a platform credential and your code is not meant to hold it. The name stays so that "nothing was sent" and "the platform removed it" are different things. Nothing else is removed —authorization,cookieandx-cai-key-idall arrive intact, because they are yours to read.
A GET-invoked function used to receive a literally empty event; this is what it receives instead. To restore the old shape on a handler that round-trips the whole event, set CRUSOE_EVENT_HEADERS=off. Full detail, including the webhook case and the logging warning, is in HTTP and events.
Set the HTTP status code
The HTTP status of the response defaults to 200. To set a different one, include a statusCode key in your return value:
def handle(event):
if "user_id" not in event:
return {"statusCode": 400, "error": "user_id is required"}
return {"statusCode": 200, "user": event["user_id"]}
Five things to know:
- The whole return value becomes the response body, including the
statusCodekey itself. - A whole number in the
100–599range behaves identically in all four runtimes. If you want one rule to remember, that is the rule. - Go, Ruby and Node.js also accept a string of digits —
"201"answers201— which is the shape a status arrives in when it is relayed from an environment variable, a config file, or an upstream response. Python needs a number. - A value that is not a usable status (
"created",42,0,true) is ignored by Go and Ruby: the response is200and your body comes back whole. Python and Node.js are less forgiving — an unusable status there costs the caller the entire response, because the connection closes with nothing on it. If you relay a status you did not choose, check it before you return it. - For CloudEvent deliveries the platform throws the return value away and answers
204no matter what. See HTTP and events.
The four runtimes
Each tab below is a complete function. Write the file, deploy it, and you have an HTTPS endpoint — nothing else is required.
All three interfaces deploy the same source, but the console applies limits the CLI does not: text files only, 1 MiB per file, 16 MiB across the files you type in the editor, and 32 MiB for a ready-made .tar.gz. A file holding a NUL byte is refused as is not a text file before anything uploads, so wheels, images, and compiled modules have to be baked into a base image or deployed with platformctl.
- Python
- Node.js
- Go
- Ruby
- File:
handler.py, definingdef handle(event).async def handle(event)works too — the shim awaits it; see synchronous or asynchronous above. - Dependencies: an optional
requirements.txtnext tohandler.py. Pip installs it when your image is built. - Sibling files:
handler.pycan import other.pyfiles in the same directory. The whole directory is uploaded, and Python can import from it.
Complete minimal example:
def handle(event: dict) -> dict:
name = event.get("name", "world")
return {"statusCode": 200, "body": f"hello, {name}, from a Python function"}
Deploy it:
- platformctl
- curl
- Console
Python is the default, so no flag is needed:
platformctl functions deploy ./my-function --name my-function
tar -czf my-function.tar.gz -C my-function .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=my-function" \
-F "framework=function" \
-F "runtime=python" \
-F "code=@my-function.tar.gz"
Compute → Functions → Deploy function, leaving Language on Python — it is the default. The editor opens on a handler.py tab and an optional requirements.txt tab, which pip installs at build time when it is not empty.
- File:
handler.js, exportinghandle. Bothmodule.exports = { handle }andmodule.exports = fnare accepted. - Dependencies: an optional
package.jsonnext tohandler.js. npm installs the production dependencies when your image is built, into the handler's own directory. - Async is supported here. The Node.js shim awaits your return value, so
async function handle(event)works exactly as well as a plain one. The same is true of Python; Go and Ruby handlers are synchronous.
Complete minimal example:
'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 just as valid:
exports.handle = async function (event) {
const data = await fetchSomething(event.url);
return { statusCode: 200, data };
};
Deploy it:
- platformctl
- curl
- Console
The runtime is detected from handler.js, so no flag is needed:
platformctl functions deploy ./my-function --name my-function
tar -czf my-function.tar.gz -C my-function .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-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 swaps the handler tab to handler.js, regenerates the starter code for it, and swaps the dependency tab to package.json, which the build runs npm install --omit=dev against when it is not empty.
Set the language before you write any code — see how the platform picks your runtime for what happens if you do it the other way round.
Go is the compiled runtime, and it works differently from the other three. At build time the platform compiles your handler.go into its own shim, producing one self-contained program. Your code therefore becomes part of the shim's Go module. That has one consequence you cannot work around: your handler must use only the Go standard library. There is no way to add third-party Go libraries today.
- File:
handler.go, withpackage main, exporting exactly this signature:
func Handle(event map[string]any) (map[string]any, error)
- Return the result map and a
nilerror. A non-nil error counts as a handler failure, which becomes a500response for a plain HTTP call. - Because the signature is checked by the compiler, getting it wrong fails the build, not the request.
- Any other
.gofiles in the directory are compiled along withhandler.go: one flatpackage main, no subdirectories, andmain.gois the runtime's own file name. Go functions has the detail.
Complete minimal example:
package main
func Handle(event map[string]any) (map[string]any, error) {
name, _ := event["name"].(string)
if name == "" {
name = "world"
}
return map[string]any{
"statusCode": 200,
"body": "hello, " + name + ", from a Go function",
}, nil
}
Deploy it:
- platformctl
- curl
- Console
The runtime is detected from handler.go:
platformctl functions deploy ./my-function --name my-function
tar -czf my-function.tar.gz -C my-function .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=my-function" \
-F "framework=function" \
-F "runtime=go" \
-F "code=@my-function.tar.gz"
If the directory has no handler.go, the build fails with:
go function requires a handler.go with a Handle(event) func
Compute → Functions → Deploy function, then set Language to Go. The picker swaps the handler tab to handler.go and regenerates the starter code for it. There is no dependency tab to fill in: the form empties the one that was there and explains why.
Go functions are compiled into the shim binary, which supplies its own go.mod - so there is no dependency file, and handler.go can only use the standard library.
Set the language before you write any code — see how the platform picks your runtime for what happens if you do it the other way round.
- File:
handler.rb, defining a top-levelhandle(event)method. - Dependencies: an optional
Gemfilenext tohandler.rb. Bundler installs the gems when your image is built. - The status key is the string
'statusCode', not a symbol.
Complete minimal example:
def handle(event)
name = (event['name'] if event.is_a?(Hash)) || 'world'
{ 'statusCode' => 200, 'body' => "hello, #{name}, from a Ruby function" }
end
Deploy it:
- platformctl
- curl
- Console
The runtime is detected from handler.rb:
platformctl functions deploy ./my-function --name my-function
A cold-cache Ruby build runs Bundler and can cross the CLI's five-minute wait. Raise it with CAI_DEPLOY_TIMEOUT if you hit that.
tar -czf my-function.tar.gz -C my-function .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=my-function" \
-F "framework=function" \
-F "runtime=ruby" \
-F "code=@my-function.tar.gz"
Compute → Functions → Deploy function, then set Language to Ruby. The picker swaps the handler tab to handler.rb, regenerates the starter code for it, and swaps the dependency tab to Gemfile, which the build runs bundle install against when it is not empty.
Set the language before you write any code — see how the platform picks your runtime for what happens if you do it the other way round.
framework is always the bare word functionWhichever language you chose, the framework field is function and the language goes in the separate runtime field. Sending framework=function-nodejs is a 400, because the hyphenated tokens are internal names the platform derives for itself.
How the platform picks your runtime
Through the API, the runtime is the runtime form field on POST /v1/agents, sent alongside framework=function. Valid values are python, nodejs, go, and ruby, and python is the default. See the agents API reference.
platformctl functions deploy chooses for you, in one of two ways. An explicit --runtime flag always wins. Otherwise the CLI reads the file names in your directory: handler.js means nodejs, handler.go means go, handler.rb means ruby, and anything else means python. It then sends framework=function and that language in the separate runtime field, which is what the API expects.
In the console, the Language field on Deploy function sends the same runtime value. It does one more job there: it decides which files you are about to edit. Choosing Go swaps the handler tab to handler.go and rewrites the starter code for it, because each shim loads its own file name and nothing else. That is why the field sits above the code editor rather than below it.
The CLI passes a --runtime value it does not recognize straight through, rather than rejecting it locally. That way a runtime added to the platform works before the CLI has heard of it. The API is what validates the set, so --runtime golang comes back as:
unsupported 'runtime': one of "python", "nodejs", "go", "ruby"
Once deployed, the platform remembers the language. It folds the runtime into the function's stored framework token — function, function-nodejs, function-go, or function-ruby — so a redeploy rebuilds it in the same language automatically.
Detection is based on file names. A handler.js sitting next to your handler.py makes platformctl functions deploy choose Node.js. You get a Node.js function instead of the Python one you meant to ship. So deploy from a directory that holds only the handler you intend to ship, or pass --runtime python to say it outright. The CLI prints the runtime it picked on the upload line, so read that before the build starts.
Language renames the handler tab only while the code in it is still the generated example. Once you have edited that file, switching language leaves it alone and tells you so:
You have edited handler.py, so the example was left alone. The function will still deploy as Node.js - but handler.py is not the file the Node.js shim loads (handler.js), so rename it or clear your edits.
Deploy anyway and the console refuses before it uploads anything, because the handler file does not match the language:
There is no handler.js at the top level of this source, and that is the file the Node.js shim loads. Rename your handler, or change the Language.
Redeploying an existing function does not show the field at all. A function keeps the language it was built with, the console sends no runtime on an update, and the check above runs against whichever handler.* file your source actually contains.
Next steps
- Language guides — the definitive per-runtime guide: deploy, triggers, secrets, invoke/stream, updates, and common bugs.
- Trigger guides — HTTP, Scheduled, Pub/Sub, and ObjectStore worked examples.
- HTTP and events — GET vs. POST, the 8 MiB event cap, and CloudEvents.
- Troubleshooting — build failures and runtime errors, with the real messages.