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.

Two things are not shared, and both bite: whether your handler may be async, and what the shim does with a POST body that is not a JSON object. Each has its own section below.

Synchronous or asynchronous?

This is the first of the two places the four runtimes genuinely differ. Get it wrong in Python and you hit the hardest failure on the platform to diagnose.

RuntimeAsync handler
pythonNo. Your handler must be a plain def.
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.
async def handle in Python fails with no HTTP response at all

The Python shim calls your handler and converts what it returns into JSON. An async def handle does not return a result. It returns a coroutine: a promise of work still to be done. The shim never awaits that promise, so it tries to convert the coroutine itself, and that fails. By then the request has moved past the shim's error handling. Nothing writes an HTTP response, and the connection just closes.

What you see from the outside is not a 500. It is nothing:

curl: (52) Empty reply from server

What you see in platformctl logs:

RuntimeWarning: coroutine 'handle' was never awaited
Exception occurred during processing of request from ('10.42.1.7', 52434)
Traceback (most recent call last):
...
TypeError: Object of type coroutine is not JSON serializable

Notice what is missing from those logs: any handler raised: line. That message appears only when your handler raises an exception inside the shim's try block. This failure happens outside it, so the traceback comes from the HTTP server instead of the shim's error path. You get no status code, no error body, and no message naming your function.

The fix: make handle a plain def. If you need to call async code, run it yourself:

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}

An async Python handler fails differently when a CloudEvent calls it, and even more quietly. A CloudEvent is an event delivered in a standard envelope that says what happened, where, and when. The shim answers 204 to acknowledge that delivery before it ever looks at your return value. So the delivery looks successful, while your handler's body never ran at all.

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

This is the second place the four runtimes genuinely differ, and an object-store trigger walks straight into it. 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. Only the Python shim turns that into an event.

RuntimePOST body that is not a JSON object
pythonText becomes {"data": "<text>"}. Bytes that are not valid UTF-8 become {"data_base64": "..."}. A JSON array or scalar is wrapped as {"data": ...}. Your handler always receives a dictionary.
nodejs400 with {"error": "invalid JSON body: ..."}, and handle is never called. A JSON array or scalar does parse, and is passed straight through unwrapped — so handle receives an array or a number rather than an object.
goThe same 400. A JSON array or scalar is refused too, because the shim unmarshals the body into map[string]any.
rubyThe same 400 as Node.js, unwrapped array or scalar included.

The non-Python failure is a quiet one. The trigger's poller reads the object, POSTs it, takes the 400, and moves on. platformctl logs shows no handler raised: line and no traceback, because the shim rejected the body before it ever called handle. A bucket-triggered function written in Node.js, Go, or Ruby therefore only works if every object dropped in the bucket is a JSON object. For anything else, write it in Python.

CloudEvent attributes: _cloudevent, Python only

The Python 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.

handler.js, handler.go, and handler.rb have no equivalent. Those three shims drop the attributes.

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"]}

Two things to know:

  • The whole return value becomes the response body, including the statusCode key itself.
  • 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). Use a plain def, never async def — 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.