Skip to main content

Python functions

Python is the default runtime: deploy a directory with no --runtime flag and you get Python. 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.py, defining def handle(event) -> dict. Use a plain defnever async def. The shim never awaits anything, so an async def handle returns a coroutine the shim cannot serialize, and the caller gets curl: (52) Empty reply from server instead of a response. See common bugs.
  • Event: one argument, a dict. GET produces {}; a POST's JSON body becomes the dict.
  • Return: a dict. The whole thing becomes the JSON response body. Include "statusCode" to set the HTTP status (default 200).
  • Dependencies: an optional requirements.txt next to handler.py, installed once at build time. Sibling .py files can be imported — the whole directory is uploaded.

Scaffold

my-function/
├── handler.py
└── requirements.txt # optional

The smallest complete function, from examples/functions/hello-http in the examples repository:

def handle(event: dict) -> dict:
return {"statusCode": 200, "body": "hello from hello-http"}

A more realistic shape with dependencies and a sibling module:

# handler.py
import requests # from requirements.txt
from helpers import render # sibling file helpers.py

def handle(event: dict) -> dict:
name = event.get("name", "world")
r = requests.get("https://example.com", timeout=5)
return {"statusCode": 200, "body": render(name), "upstream": r.status_code}

Deploy

Python is the default runtime, so no flag is needed:

platformctl functions deploy ./my-function --name my-function

You should see the upload line name the runtime it picked — read it before the build starts:

packaging ./my-function...
uploading my-function (1.2 KiB, framework=function, runtime=python)...

That line names the function, not the directory, so it is the --name you passed (or the directory's base name when you did not). Then the CLI waits for the build. The function is callable when its state reaches ready.

Per-trigger-type handlers

Your handler is the same handle(event) no matter what fires it. What changes is how and when the request arrives — and whether your return value is seen by anyone.

HTTP

Direct calls to the function's public URL. The return value is the response.

def handle(event: dict) -> dict:
if "user_id" not in event:
return {"statusCode": 400, "error": "user_id is required"}
return {"statusCode": 200, "user": event["user_id"]}

Scheduled (cron)

A scheduled trigger POSTs your configured payload every interval, with a ce-id header — so the shim treats it as a CloudEvent, answers 204, and discards your return value. The function runs for what it does:

def handle(event: dict) -> dict:
job = event.get("job", "default")
print(f"scheduled run: {job}") # observable via logs --history
run_job(job)
return {"statusCode": 200, "ok": True} # discarded; keep it for direct HTTP calls

Pub/Sub

A Pub/Sub trigger pushes each topic message as a CloudEvent, in structured mode by default: the message's bytes travel base64-encoded in the envelope's data_base64, and the shim decodes and parses them, so your handler sees the published message itself rather than the envelope around it.

def handle(event: dict) -> dict:
order_id = event.get("order_id")
ce = event.get("_cloudevent", {})
print(f"processing order {order_id} (attempt {ce.get('deliveryattempt')})")
process(order_id)
return {"statusCode": 200}

Everything outside the payload arrives under event["_cloudevent"]: subscription, deliveryattempt, the topic name in subject, and the publisher's attributes when the message carried any. A payload that is not JSON arrives as {"data": "<text>"}, and one that is not valid UTF-8 as {"data_base64": "<base64>"} — so a handler fed by publishers you do not control should check the shape before indexing into it.

In binary mode the payload is the raw body and the same attributes arrive as ce-* headers, which the shim folds into _cloudevent with the prefix stripped — with one difference worth knowing before you write ce["attributes"]: a publisher's own attributes are namespaced onto the wire as ce-attr-<name>, so they surface as separate attr-<name> keys rather than as one attributes dict. Read them defensively if your trigger might be switched to binary mode.

ObjectStore (bucket events)

An ObjectStore trigger polls the bucket and fires once per new object. The body is the object, not a description of it: the poller reads the bytes and POSTs them to your function, so the handler never talks to the bucket and needs no S3 client and no bucket credentials of its own. A text object arrives as {"data": "<the file's text>"}, bytes that are not valid UTF-8 as {"data_base64": "<base64>"}, and an object that happens to hold a JSON object is the event.

def handle(event: dict) -> dict:
text = event.get("data", "") # the file's content
ce = event.get("_cloudevent", {}) # every ce-* header, prefix stripped
# Do not read ce["subject"] as a filename. This source sets it to the name
# of the source ("aws-s3-source"), not the object key, so it has no
# extension and `subject.endswith(".md")` rejects every file. Take the
# identity from the content instead, or put one inside the object.
title = next((ln[2:].strip() for ln in text.splitlines() if ln.startswith("# ")), "untitled")
print(f"ingested {len(text)} bytes: {title}")
return {"statusCode": 200}

The poll interval defaults to 60 seconds, so a drop can take a full minute to arrive — a function that looks dead in the half-minute after an upload is usually just early. The delivery carries a ce-id header, so as with any CloudEvent the shim ACKs 204 and discards your return value; the function runs for what it does.

Environment and secrets

Both arrive as ordinary environment variables; read them with os.environ. The difference is read-back: env vars can be read back, secrets never can.

# plain config (readable)
platformctl agents env set my-function LOG_LEVEL=debug EXTERNAL_BASE=https://api.example.com

# credentials (write-only; only the key name is ever shown)
platformctl secrets set my-function EXTERNAL_API_KEY=abc123

Reading them in the handler — tip: read at module level, not per request. Env for a run is fixed per revision, so a module-level read does the lookup once instead of on every invocation:

import os

API_KEY = os.environ.get("EXTERNAL_API_KEY") # read once at import

def handle(event: dict) -> dict:
if not API_KEY:
return {"statusCode": 500, "error": "EXTERNAL_API_KEY is not configured"}
...

Key names must match ^[A-Za-z_][A-Za-z0-9_]*$my-key is rejected with 400. Every change to env or secrets rolls a new revision, an immutable snapshot of code plus settings. Full details: secrets and environment variables.

Project secrets: bind, then apply

The commands above write a credential onto one function. A project secret is stored once and bound into as many workloads as you like, each under whatever variable name its code already expects, so rotating the stored value reaches all of them without editing any of them.

Recording a binding delivers nothing. It says "this function reads this project secret as this variable" and changes nothing that is running:

printf %s "$STRIPE_KEY" | platformctl secrets put stripe-key # store the value once
platformctl secrets bindings set my-function STRIPE_KEY --secret stripe-key
platformctl secrets bindings apply my-function # <- this is what delivers it

There is no --value flag on secrets put, deliberately: the value comes from a pipe or --value-file, never from argv where a shell history file would keep it. bindings set names a secret and carries no value at all.

apply (POST /v1/projects/{id}/agents/{agent}/secret-maps:apply) reads every bound secret, writes the values into the function's own Secret, and rolls a revision so the running function picks them up. It is all or nothing: if one bound secret cannot be read, nothing is written. In the console the same flow is Bind a secret on the function's page, then the confirm dialog titled Apply bindings to my-function? with the Apply to my-function button.

Bind, then apply, then deploy — a bound secret is absent on the first revision

A deploy rolls its revision immediately, before any binding has been applied. So a function you deploy first and bind second has a first revision with no bound values on it, and a handler that reads one at import fails the shim's load — every call returns the same error until you apply. Either apply the bindings after the deploy (the apply rolls a fresh revision that carries them), or record them before the first deploy: applying to a function that has not been deployed yet simply writes the values — there is no running revision to roll — and the first deploy picks them up.

Reading a bound secret at module scope is the pattern that trips this. The os.environ.get-plus-guard shown above stays correct — module scope is still the right place for the read. What fails is a bare os.environ["STRIPE_KEY"]: on a revision that has no bound value yet it raises KeyError during the shim's load, so every request comes back with the same error and the handler body never runs.

Invoke pattern

Three doors, one function. The shared platform invoke path delivers your text to the handler as event["message"] — and drops every other key, because the endpoint decodes the body into a fixed shape (session_id, user_id, message, memorize) before re-sending:

platformctl invoke my-function "some text"

The envelope reports an agent's output field, which a function's return value has no use for, so you get a blank line and a session id:


(session: 3f6c1f0e-9c1c-4f1a-8f2e-2a0d5f6b7c81)

For arbitrary JSON bodies, call the function's own URL — the full body reaches the handler untouched:

curl -s -X POST "$FN_URL" \
-H 'Content-Type: application/json' \
-d '{"user_id": "u-123", "verbose": true}'

$FN_URL is the public_url field on GET /v1/agents/my-function, and the function must be published (CAI_EXPOSE_EXTERNAL=true in its env) for that URL to answer.

Invoke/stream (NDJSON)

The shared path also streams: POST /v1/agents/{name}/invoke/stream answers application/x-ndjson — one complete JSON object per line, in the order things happened. For a function (which has no agent events to stream) you typically see a single terminal event, but the contract is the same 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"}'

Each line is a self-contained JSON event. Parse line-by-line; never assume the whole body is one JSON document. Details and event shapes: invoke.

# client-side, one event per line
import json, requests

r = requests.post(url, json={"message": "hi"}, stream=True)
for line in r.iter_lines():
if line:
event = json.loads(line)
print(event)

Logs and status

platformctl status my-function # state: building -> deploying -> ready
platformctl logs my-function # live only; empty when scaled to zero
platformctl logs my-function --history # persisted lines, survive scale-to-zero

Two rules that save hours: an idle function scales to zero, so plain logs has nothing to follow — use --history. And never combine the two flags; the CLI rejects it verbatim:

--follow and --history are mutually exclusive: --history reads persisted logs, --follow tails a running instance

print() lines land in logs; uncaught exceptions land there with a full stack trace even when the caller only got {"error": "handler raised: ..."}.

Update (ship a new version)

Run the same deploy command again. Each deploy replaces the whole source tree — there is no partial state — and rebuilds in the same runtime automatically, because the language is folded into the function's stored framework token:

platformctl functions deploy ./my-function --name my-function

In the console, the function page's Update source action does the same from the browser. The console deliberately has no Redeploy button on functions, but the underlying POST /v1/agents/{name}/redeploy endpoint works on functions too — it rebuilds from stored source in the recorded runtime.

Calling other services

A function reaches other platform services over HTTP with stdlib or requests. Credentials for them belong in secrets (above).

The platform injects the addresses, so never hardcode a service name. What a live function is handed:

VariableWhat it addresses
CAI_API_URLthe platform API — agents, functions, secrets
CAI_PUBSUB_URLPub/Sub — publish to a topic
CAI_VECTORDB_URLVectorDB — write and query an index
EMBED_BASE_URLthe embedding endpoint, OpenAI-compatible, path ends in /v1
EMBED_MODELqwen-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, which is the reason to read it from os.environ at every start rather than paste a value into your code — print(os.environ["CAI_VECTORDB_URL"]) shows what a given revision was handed. CAI_PROJECT_ID, CAI_PROJECT_KEY, MCP_SERVERS and CRUSOE_REQUEST_TIMEOUT_SECONDS arrive with them. Exactly four of the injected names may be overridden by your own env — MODEL_BASE_URL, CHAT_MODEL, EMBED_BASE_URL and EMBED_MODEL. Setting any of the others is refused by the env write with the reason in the response, rather than stored, echoed back by GET /env, and then discarded at deploy.

Memory Store: a function connects to its project's own instance directly, at connection.host on port 6379. The platform writes an egress rule for exactly that — your project's workloads to your project's memory store — so a plain redis:// connect from inside the project works. The TLS (rediss://) endpoint exists for reaching the instance from outside the platform — from your laptop, say — which is not where a function runs. Read connection.host from the API on every start rather than hardcoding it, and take the password from the ms-<name>-credential Secret; both are in connect from workloads.

VectorDB: indexes are reachable over HTTP from inside your project. A function can embed (calling EMBED_BASE_URL), then write or query points:

import os, requests

VDB = os.environ["CAI_VECTORDB_URL"].rstrip("/") # injected by the platform
PROJECT = os.environ["CAI_PROJECT_ID"] # injected
TOKEN = os.environ.get("VECTORDB_TOKEN") # your own service account key, bound as a secret

def handle(event: dict) -> dict:
r = requests.post(f"{VDB}/v1/projects/{PROJECT}/indexes/docs:query",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"vector": event["vector"], "top_k": 5}, timeout=10)
r.raise_for_status()
return {"statusCode": 200, "matches": r.json()["results"]}

Two things that bite here. The query is a custom methodPOST .../indexes/{name}:query, with the results under results, not points. And the vector's length has to match the index exactly, or you get 400 query vector has 768 dimensions; index "docs" expects 4096. If you embed with the platform's qwen-embedding, create the index without naming a width and the two match automatically. See VectorDB.

Pub/Sub (publish): POST to $CAI_PUBSUB_URL/v1/projects/{project_id}/topics/{topic}:publish with {"messages": [{"text": "..."}]}, which answers {"message_ids": [...]}. This is the natural way to fan work out: HTTP in, message published, another function downstream consumes it. The event-driven functions tutorial shows the consumer side end to end.

The credential is the half that is easy to get wrong. CAI_PROJECT_KEY cannot publish. It is the workload identity the platform injects, and a workload key holds no authority on any project route; because an inaccessible project is answered 404 rather than 403, you get 404 not found on a topic you can see in the console, with nothing pointing at the credential. Its one power is minting a short-lived token to read its own project's secrets. To publish, create a service account key, store it as a project secret, bind it to the function, and send it as Authorization: Bearer.

Firewall note: your project's 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 — a service listening elsewhere is unreachable from your function no matter what its URL says.

Common bugs

async def handle — the silent killer. Symptom: curl: (52) Empty reply from server, and in logs RuntimeWarning: coroutine 'handle' was never awaited plus TypeError: Object of type coroutine is not JSON serializable. Notably no handler raised: line — the failure happens outside the shim's try block. Fix: plain def; run async code inside with asyncio.run(...):

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}

On a CloudEvent delivery the same bug is worse: the shim ACKs 204 before looking at the return value, so the delivery looks successful while your handler's body never ran.

invalid Content-Length (400). Python-specific: a Content-Length header that is not a number is refused before your handler runs. Other runtimes never read that header. Fix: send a numeric content length or none — proxies and hand-rolled HTTP clients that write Content-Length: unknown are the usual source.

Stray handler file changes the detected runtime. A leftover handler.js next to handler.py makes the CLI pick Node.js. Deploy from a clean directory or pass --runtime python; read the upload line.

Over 8 MiB bodies are rejected with 413 ({"error": "body exceeds 8388608 bytes"}) before the handler runs. Send a reference — an object-storage location or a record id — instead of the data.

Module-level exceptions kill every request identically. An import error or a raised exception at module scope fails the shim's load and turns every call into the same error. Keep module scope cheap: imports, constant definitions, and env reads only.