HTTP and events
This page is the full request and response contract for functions. It covers what your handler's event holds for each kind of request, and how your return value turns into the HTTP response. It also covers how a CloudEvent delivery behaves differently from a plain HTTP call.
How a request becomes an event
Every function sits behind a small web server the platform provides, called the shim. The shim maps requests to your handler like this:
| Request | What your handler gets |
|---|---|
GET /healthz | Nothing — the shim answers 200 with {"status": "ok"} itself. This is the platform's health check. |
Any other GET | An empty {} event. |
POST with a JSON object body | The parsed JSON body as the event. |
POST with a body that is not JSON | Python: {"data": "<the body as text>"}, or {"data_base64": "<base64>"} if the bytes are not valid UTF-8. Node.js, Go, and Ruby: rejected with 400 before your handler runs. |
POST with a JSON scalar or list | Python: wrapped as {"data": ...}. Node.js and Ruby: passed through unwrapped, so your handler receives a value that is not a dictionary. Go: rejected with 400. |
POST that is a CloudEvent | The event data — see CloudEvents below. |
Your handler sees only the body. It never receives the URL path or the query string, and the only headers that reach it are the CloudEvent attributes described in CloudEvent attributes below. So you have two options if you need one workload to answer on several paths. Deploy a separate function per path, or use a serverless container service, where you control the whole web server yourself.
A body that is not JSON
Only the Python runtime accepts one. The other three parse the body as JSON before your handler is reached, and a body that will not parse is answered with 400:
{"error": "invalid JSON body: ..."}
The text after invalid JSON body: is that runtime's own parser message, so it differs between Node.js, Go, and Ruby.
This is the shape an object-store trigger delivers: the body is the object's own bytes, so a dropped markdown file, CSV, or log line arrives as text rather than as JSON. On Python it reaches your handler as event["data"]. On Node.js, Go, and Ruby the same drop never reaches your handler at all — the shim answers 400, the poller treats that as a permanent failure and does not retry, and nothing appears in your function's logs because your code never ran. Write that function in Python, or have the producer write JSON into the bucket.
The 8 MiB event cap
A POST body may be at most 8 MiB. A larger body is rejected before your handler runs, with status 413:
{"error": "body exceeds 8388608 bytes"}
The Go runtime answers the same 413 with a different body:
{"error": "body too large or unreadable"}
On the Python runtime, a request whose Content-Length header is not a number is rejected with status 400:
{"error": "invalid Content-Length"}
The Node.js, Go, and Ruby shims never read the Content-Length header at all. They measure the body as it arrives, or after reading it, and reject it with 413 only when it really is too large.
Uploading your function's code is a separate limit: up to 100 MiB through the CLI. See limits.
How your return value becomes the response
- The whole return value becomes the JSON response body.
- The status defaults to
200. Include a"statusCode"key to change it. So{"statusCode": 404, "error": "no such record"}produces an HTTP 404 whose body is that same JSON. The runtimes page shows this convention in each language. - If your handler throws an exception, the platform catches it and answers
500. Callers get a real response instead of a dropped connection:
{"error": "handler raised: division by zero"}
The text after handler raised: is your language's own error message — check the function's logs for the full stack trace.
Invoke through the platform
You can call the function's URL directly, as above. You can also call it through the platform's shared invoke path, the same one agents use:
- platformctl
- curl
- Console
platformctl invoke my-function "some text"
You should see a blank line and a session id — the invoke envelope reports an agent's output field, and a function's return value has no output key:
(session: 3f6c1f0e-9c1c-4f1a-8f2e-2a0d5f6b7c81)
curl -s -X POST "$CAI_API/v1/agents/my-function/invoke" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H 'content-type: application/json' \
-d '{"message":"some text"}'
You should see your handler's own return value, with two keys merged in:
{
"statusCode": 200,
"body": "hello, world, from a Python function",
"session_id": "3f6c1f0e-9c1c-4f1a-8f2e-2a0d5f6b7c81",
"user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}
The invoke path does not wrap a function in an agent envelope. It passes the handler's JSON body through as-is and fills in session_id and user_id only where the handler did not set them. There is no output, tool_calls, or events key unless your handler returns one — which is why platformctl invoke in the other tab prints a blank line.
The HTTP status is passed through too, so it is whatever your handler's statusCode said. A handler returning {"statusCode": 400, ...} makes this curl answer 400, not 200.
Open the function's page and use its Invoke panel. It sends the same body this path always sends.
However you call it, the invoke path delivers your text to the handler as event["message"]. Only session_id, user_id, message, and memorize survive the trip — the endpoint decodes the body into a fixed shape and re-sends that, so any other key you add is dropped before your handler sees it. Call the function's own URL when you need to deliver an arbitrary body. See invoking agents for the endpoint details.
Deploying and managing functions always requires authentication, and so does calling one through the invoke path. INVOKE_AUTH_REQUIRED defaults to true, so send a bearer token. Without one you get 401:
{"error": "this platform requires authentication to invoke agents (INVOKE_AUTH_REQUIRED=true). Sign in (POST /v1/auth/login) and send 'Authorization: Bearer <token>'"}
This gates invoke, invoke-stream, and memorize — the data plane. A platform administrator can open it with INVOKE_AUTH_REQUIRED=false, but that opens every agent and function in every project at once: name resolution is not project-scoped, so an anonymous caller can invoke a workload it has nothing to do with. To publish one function publicly, put it behind the gateway with auth mode none and a rate limit instead. See API authentication.
CloudEvents delivery
A CloudEvent is a small, industry-standard envelope for event data. It says what happened, where, and when. Event systems use it to deliver messages to whoever subscribed. The shim treats a CloudEvent differently from a plain HTTP call, no matter who sent it.
A request counts as a CloudEvent if either of these is true:
- It carries a
Ce-Idheader. That is binary mode: the labels ride in headers and the event data is the body as-is. - Its content type is
application/cloudevents+json. That is structured mode: the whole envelope is the body, and your handler receives only itsdatafield.
Four rules change for CloudEvents:
- The response is always an empty
204. That is the acknowledgement the sender expects, often shortened to "ACK". Your handler's return value is thrown away. An event handler runs for what it does — writing to a database, logging, calling another service — not for what it returns. - Handler exceptions return
400, not500. A400tells the sender this event failed permanently, so it stops trying. A5xxwould say "try again", and every retry would fail the same way forever. The body has the same shape either way:{"error": "handler raised: ..."}. - On the Python runtime, the envelope's attributes are attached to the event as
event["_cloudevent"]. See below. - Nothing else changes. The 8 MiB cap still applies, and the event still reaches the same
handle(event)function.
CloudEvent attributes
The event data alone often does not say what the event was about. An object-store trigger is the clearest case: the body is the object's bytes, so without the envelope your handler holds a file's contents and cannot say which file it is.
On the Python runtime, the shim attaches those attributes as event["_cloudevent"], a dictionary:
- Binary mode — every
ce-*request header, with thece-prefix stripped and the name lowercased. ACe-Subject: a.mdheader becomesevent["_cloudevent"]["subject"]. - Structured mode — every key of the envelope except
dataanddata_base64. For a Pub/Sub push that isspecversion,type,source,id,time,subject(the topic name),datacontenttype,subscription,deliveryattempt, andattributeswhen the message carries any.
Every attribute is passed through rather than a chosen few, because the names differ by source. Read the key you need defensively — event.get("_cloudevent", {}).get("subject") — rather than assuming a source populates it:
def handle(event):
ce = event.get("_cloudevent", {})
print(f"{ce.get('type')} from {ce.get('source')}")
return {}
Two limits worth knowing before you build on this. If your payload already has a _cloudevent key of its own, it wins and the attributes are not attached — losing a caller's data to platform metadata is the worse failure. And the Node.js, Go, and Ruby runtimes do not attach attributes at all; on those, event["_cloudevent"] is simply absent.
Try it with curl
Both examples post to the function's own address, so set that first. It is the public_url from GET /v1/agents/{name}, and the function has to be published for it to answer — see call it at its public URL.
export FN_URL=https://my-function-ab12cd.apps.codyhill.dev
Binary mode — the Ce-Id header marks it as a CloudEvent:
curl -i -X POST "$FN_URL" \
-H 'Ce-Id: 1234' \
-H 'Ce-Specversion: 1.0' \
-H 'Ce-Type: demo.event' \
-H 'Ce-Source: docs-example' \
-H 'Content-Type: application/json' \
-d '{"message": "an event happened"}'
You should see (headers abridged):
HTTP/1.1 204 No Content
Structured mode — the envelope is the body, and the handler receives only the data field (here, {"message": "an event happened"}):
curl -i -X POST "$FN_URL" \
-H 'Content-Type: application/cloudevents+json' \
-d '{"specversion": "1.0", "id": "1234", "type": "demo.event",
"source": "docs-example", "data": {"message": "an event happened"}}'
You should see:
HTTP/1.1 204 No Content
A 204 only tells you the delivery was accepted. To confirm the handler actually ran, read the persisted logs — they survive scale-to-zero, and a function that answered a single event is usually back at zero by the time you look:
- platformctl
- curl
- Console
platformctl logs my-function --history
curl -s "$CAI_API/v1/agents/my-function/logs/history" \
-H "Authorization: Bearer $CAI_TOKEN"
Open the function's Logs tab. It shows persisted history when nothing is running, and follows the live copy when something is.
What about schedules and other triggers?
Your function itself only ever sees an HTTP request. What decides when that request arrives can be a trigger, a separate object that fires a workload for you. A trigger fires on a repeating schedule, on a Pub/Sub message, or when an object appears in a bucket.
Triggers live on the Serverless API rather than on functions, but a trigger can target a function. Leave target.path at its default of /, which is where the function shim answers. Your handler then receives each delivery as a POST carrying the payload you configured, unchanged.
That delivery arrives with a ce-id header, so by the rules above it is a CloudEvent: your function answers 204, its return value is discarded, and a raised exception becomes a 400 that the trigger treats as permanent and never retries.
Fire my-function every 15 minutes:
- platformctl
- curl
- Console
platformctl serverless triggers create quarter-hourly \
--target my-function \
--type schedule \
--cron '*/15 * * * *' \
--payload '{"job":"sweep"}'
curl -s -X POST "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "quarter-hourly",
"target": {"service": "my-function", "path": "/"},
"source": {"type": "schedule",
"schedule": {"cron": "*/15 * * * *", "payload": "{\"job\":\"sweep\"}"}}}'
Triggers are on the serverless API, so this one call uses $CAI_SERVERLESS_API and $CAI_PROJECT rather than $CAI_API. Both APIs are served on the same hostname.
The function's own page has an Add trigger button. It opens the same dialog the Serverless page uses, with this function already fixed as the target. Set Trigger to Schedule, name it, and give it a Cron and a Payload.
Coming the other way works too: the Serverless page's trigger dialog lists this project's functions and agents in its target picker alongside its serverless services.
Set source.schedule.time_zone (--time-zone) if the schedule matters to a human. Leave it out and the schedule runs in UTC.
The triggers page documents the exact request a trigger sends, and what your status code does to retries.
For a worked example of wiring events to functions, see the event-driven functions tutorial; for the messaging service itself, see Pub/Sub.
Summary
| Situation | Status | Body |
|---|---|---|
GET /healthz | 200 | {"status": "ok"} (shim answers) |
GET anything else | your statusCode (default 200) | your return value as JSON |
POST with JSON object body | your statusCode (default 200) | your return value as JSON |
POST body that is not JSON (Python runtime) | your statusCode (default 200) | your return value as JSON — handler gets {"data": ...} |
POST body that is not JSON (Node.js, Go, Ruby) | 400 | {"error": "invalid JSON body: ..."} — handler never runs |
POST body over 8 MiB | 413 | {"error": "body exceeds 8388608 bytes"} (Go runtime: {"error": "body too large or unreadable"}) |
Bad Content-Length (Python runtime only) | 400 | {"error": "invalid Content-Length"} |
| Invoke path with no bearer token | 401 | {"error": "this platform requires authentication to invoke agents ..."} |
| Handler exception (plain HTTP) | 500 | {"error": "handler raised: ..."} |
| CloudEvent delivered successfully | 204 | empty — return value discarded |
| Handler exception (CloudEvent) | 400 | {"error": "handler raised: ..."} — stops redelivery |
Next steps
- Triggers — the exact request a trigger sends, and what your status code does to retries.
- Event-driven functions tutorial — a Pub/Sub push subscription wired to a function.
- Troubleshooting — the real error strings.
- Serverless API reference — the trigger routes.