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
All four runtimes accept one. A body that is not JSON is a normal event, not an error.
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. It reaches your handler as event["data"], whatever language you wrote it in. Bytes that are not valid text arrive instead as event["data_base64"], still encoded, for you to decode — because there is no lossless text form for them and handing you mangled characters would be worse than handing you the bytes.
Until D0144 the Node.js, Go and Ruby runtimes answered 400 invalid JSON body here, which meant an object-store drop never reached those handlers at all and nothing appeared in the function's logs. That is fixed, and every runtime has a test pinning it. If you find a page telling you to write bucket-triggered functions in Python, that text is out of date.
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: ..."}. - The envelope's attributes are attached to the event as
event["_cloudevent"], on all four runtimes. 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.
The shim attaches those attributes as event["_cloudevent"] — on all four runtimes, with the same keys:
- 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 {}
One limit 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 of the two failures.
Until D0144 the Node.js, Go and Ruby runtimes really did discard the attributes, and those three also answered 400 to any body that was not JSON. Both gaps were closed, and several pages went on describing them — including this one. If you read anywhere that attributes or non-JSON bodies are a Python-only capability, that text is out of date: all four runtimes behave the same way now, and TestTheFourShimsAgreeOnTheEventKeys fails the build if one of them drifts again.
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.net
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.
Request headers
The event says what was sent. event["_headers"] says who sent it, and how.
def handle(event):
headers = event.get("_headers", {})
print(headers.get("user-agent"))
return {"tenant": headers.get("x-tenant-id")}
It is a plain map of the request's headers, on all four runtimes, and it is present whether the request was a plain GET, a plain POST, or a CloudEvent delivery.
Three rules, and they are the whole contract:
- The names are lowercased. Read
event["_headers"]["content-type"], never["Content-Type"]. This is deliberate rather than incidental: the four runtimes disagree about header case and always will, so lower case is the one spelling a handler can write once and have work everywhere. - A header sent more than once is joined with
", ". A map cannot hold two entries under one name, and keeping only the first would silently drop half of anAcceptor aForwardedchain. - Your payload wins. If the body you send already has a
_headerskey, it is kept and the request's headers are not attached — the same rule, for the same reason, as_cloudevent.
What it is for
A GET-invoked function used to receive a literally empty event. Everything a caller told you — the content type, the tenant, the trace id, the webhook signature — was on the wire and none of it reached your code. Three things become possible:
- Verifying a webhook.
x-hub-signature-256,stripe-signatureand the rest arrive intact, so a handler can check a signature before trusting a body. The Node.js webhook fan-out tutorial readsevent["_headers"]for exactly this. - Telling callers apart on a shared function, with your own header.
- Following a request through your own logs, with
x-request-idortraceparent.
One header is removed
x-cai-internal arrives with its value replaced by [redacted]. It is a platform credential — the platform attaches it to workloads it calls, and your code is not meant to hold it. The name is still there so that "the caller sent nothing" and "the platform removed it" are different things in your handler rather than the same absence.
Nothing else is removed. In particular authorization, cookie and x-cai-key-id all arrive intact, because they are yours: authorization is what a shared-secret webhook or a gateway jwt endpoint sends you, and x-cai-key-id is what the gateway adds so you can tell which key called.
print(event) in a handler writes the caller's authorization header into your function's logs, where anyone who can read logs can read it. Log the keys you need, not the whole event.
Turning it off
_headers is attached by default. A handler that round-trips the whole event — storing it, hashing it, or validating it against a schema that rejects unknown keys — sees a new key appear under it. Set the environment variable to restore the old shape:
platformctl agents env set my-function CRUSOE_EVENT_HEADERS=off
That rolls a new revision, because environment is baked in at deploy time. on, 1, true and yes all mean on, which is also what an unset value means; off, 0, false and no mean off. Anything else is treated as on and logged, so a typo cannot silently hide your data.
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.