Skip to main content

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:

RuntimeHandler fileHandler symbolDependencies
python (default)handler.pydef handle(event)optional requirements.txt
nodejshandler.jsexports.handleoptional package.json
gohandler.gofunc Handlenone — standard library only
rubyhandler.rbdef handleoptional 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 GET request produces an empty {} event. A POST whose 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.

RuntimeAsync handler
pythonYes. The shim awaits what your handler returns, so async def handle and a plain def handle both work.
nodejsYes. The shim awaits the return value, so async function handle and a plain function handle both work.
goNo. The signature is synchronous; run your own goroutines and wait for them inside Handle.
rubyNo. 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.

Python handlers no longer have to be a plain def

Until 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.

All four runtimes deploy from the CLI

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 bodyWhat your handler receives
A JSON objectThe 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 _cloudevent key 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 an Accept chain.
  • A payload that already has its own _headers key keeps its own value, exactly as with _cloudevent.
  • x-cai-internal arrives 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, cookie and x-cai-key-id all 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 statusCode key itself.
  • A whole number in the 100–599 range 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" answers 201 — 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 is 200 and 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 204 no 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.

  • File: handler.py, defining def handle(event). async def handle(event) works too — the shim awaits it; see synchronous or asynchronous above.
  • Dependencies: an optional requirements.txt next to handler.py. Pip installs it when your image is built.
  • Sibling files: handler.py can import other .py files 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:

Python is the default, so no flag is needed:

platformctl functions deploy ./my-function --name my-function
framework is always the bare word function

Whichever 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.

A stray handler file changes what the CLI picks

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.

In the console, choose the Language before you write the code

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.