Go functions
Go is the compiled runtime, and it works differently from the other three. At build time the platform compiles your handler.go into its shim, producing one self-contained program. This page is the complete guide — the hard constraints, deploy, every trigger type, secrets, invoking, logs, updating, and the build failures you will actually meet.
The contract
- Files:
handler.go, withpackage main, exporting exactly this signature — plus any other.gofiles your function needs, which are compiled with it (see Scaffold):
func Handle(event map[string]any) (map[string]any, error)
- Standard library only. Your code becomes part of the shim's module, so there is no way to add third-party Go libraries today. No
go.modof your own, no vendoring that helps. Everything innet/http,encoding/json,log, and the rest of the standard library is available. - Signature is checked by the compiler. Get it wrong and the build fails, not the request — you find out at deploy time, which is the cheap time.
- Event: one argument,
map[string]any. APOST's JSON body becomes the map; aGETcarries no body, only its headers underevent["_headers"](see request headers). A body that is not a JSON object still reachesHandle: text arrives asevent["data"], bytes that are not valid UTF-8 asevent["data_base64"], and a JSON array or scalar wrapped asevent["data"]. You always get a map, and no body shape is rejected before your code runs. - Return:
(map[string]any, error). The map becomes the JSON response body; include a"statusCode"key to set the HTTP status (default200, and see The statusCode key for which values count). A non-nil error counts as a handler failure —500for a plain HTTP call,400for a CloudEvent. - Concurrency is your own. The signature is synchronous; run goroutines inside
Handleand wait for them before returning.
Scaffold
handler.go is the only file you must ship, and there is no dependency file:
my-function/
└── handler.go
A bigger function splits across files. Every .go file in the directory is compiled, so a helper belongs in its own file rather than crammed into handler.go:
my-function/
├── handler.go <- required: this is the file that defines Handle
├── store.go
└── render.go
Three rules follow from your code being compiled into the shim's own package:
- One flat package. Every file is
package main, and they see each other's declarations directly — there are no imports to write between them. Files in subdirectories are not part of the build, so keep them at the top level of the function directory. main.gois the runtime's own file name. Ship one and the build stops withgo function: main.go is the runtime's own file - rename yours, rather than replacing the server yourHandleruns behind. Any other name is yours._test.gofiles are ignored by the build. They are harmless to upload; they simply do not run anywhere.
The smallest complete function, from examples/functions/hello-go in the examples repository:
package main
func Handle(event map[string]any) (map[string]any, error) {
name, _ := event["name"].(string)
if name == "" {
name = "world"
}
return map[string]any{
"statusCode": 200,
"body": "hello, " + name + ", from a Go function",
}, nil
}
The statusCode key
statusCode may be a number or a string of digits. 200, the float64(200) a JSON round trip yields, and "200" are all read the same way, because a status often arrives as text — out of an environment variable, a config file, or an upstream response you are relaying — and one function must not answer 201 in Ruby or Node.js and 200 in Go. Surrounding whitespace is trimmed, so " 503 " works too.
Anything that is not a status in the 100–599 range is ignored and the response is 200: "created", 42, 0, true, a missing key. That is deliberate. An impossible code used to cost the caller the whole response — the connection closed with no reply at all — and a 200 carrying your body is a far better answer than nothing.
The whole returned map is the response body, statusCode key included. On a CloudEvent delivery the return value is discarded and the platform answers 204 whatever it holds.
Deploy
- platformctl
- curl
- Console
The runtime is auto-detected from handler.go:
platformctl functions deploy ./my-function --name my-function
You should see the upload line name the detected runtime:
packaging ./my-function...
uploading my-function (912 B, framework=function, runtime=go)...
The line names the function, not the directory — it is the --name you passed.
tar -czf my-function.tar.gz -C my-function .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer ***" \
-F "name=my-function" \
-F "framework=function" \
-F "runtime=go" \
-F "code=@my-function.tar.gz"
If the directory has no handler.go, the build fails with:
go function requires a handler.go with a Handle(event) func
Compute → Functions → Deploy function, then set Language to Go. The picker names the entry file handler.go and offers no dependency tab at all — Go functions compile into the shim's own module, so there is nothing to declare. The form refuses the deploy if handler.go has no top-level func Handle( rather than building an image that would fail to compile. Language is asked only for a new function; an existing one keeps the runtime it was deployed with.
Console upload limits are stricter than the CLI's: text files only, 1 MiB per file, 16 MiB of source in one deploy, 32 MiB for a ready-made .tar.gz.
Per-trigger-type handlers
HTTP
package main
func Handle(event map[string]any) (map[string]any, error) {
userID, _ := event["user_id"].(string)
if userID == "" {
return map[string]any{"statusCode": 400, "error": "user_id is required"}, nil
}
return map[string]any{"statusCode": 200, "user": userID}, nil
}
Scheduled (cron)
CloudEvent rules: the shim ACKs 204 and discards the return value. log.Printf is how you observe it — this is the event-logger-go example pattern:
package main
import (
"encoding/json"
"log"
)
func Handle(event map[string]any) (map[string]any, error) {
b, _ := json.Marshal(event)
log.Printf("scheduled run: %s", b)
// ... do the work ...
return map[string]any{"statusCode": 200, "ok": true}, nil
}
Pub/Sub
func Handle(event map[string]any) (map[string]any, error) {
orderID, _ := event["order_id"].(string)
log.Printf("processing order %s", orderID)
// ... process ...
return map[string]any{"statusCode": 200}, nil
}
ObjectStore (bucket events)
An object-store trigger delivers the object's own bytes as the request body. There is no JSON description of the drop — no bucket, no key, no size — and no S3 client to write, because the content has already arrived.
The Go shim hands that body to Handle whatever shape it is, so this runtime is not limited to buckets of JSON objects. A JSON object becomes the event itself. A markdown file, a CSV or a log line arrives as event["data"]; bytes that are not valid UTF-8 arrive base64-encoded as event["data_base64"]; a JSON array or scalar is wrapped as event["data"]. Your handler always gets a map, and nothing is rejected before your code runs.
The delivery's CloudEvent attributes arrive alongside it, under event["_cloudevent"]: every ce-* header with the prefix stripped, matched case-insensitively. They are there even when the object is empty, which is the only thing a zero-byte drop carries. Which attributes you get depends entirely on the source — one bucket delivery was measured carrying id, source, specversion, subject, time and type, and nothing that names the object — so log the map on your first delivery and read what your own store sends rather than assuming (object store triggers has the detail). If your payload already carries its own _cloudevent key, yours wins and nothing is attached.
func Handle(event map[string]any) (map[string]any, error) {
// The body IS the object. A two-value type assertion, always: a bucket of
// mixed objects reaches this handler as text for some and a map for others.
ce, _ := event["_cloudevent"].(map[string]any)
log.Printf("delivery attributes: %v", ce)
if text, ok := event["data"].(string); ok {
log.Printf("ingested %d bytes of text", len(text))
// ... process the raw object ...
return map[string]any{"statusCode": 200}, nil
}
// A JSON object arrived as the event itself: these fields are the file's own.
id, _ := event["id"].(string)
log.Printf("ingested record %s", id)
// ... process ...
return map[string]any{"statusCode": 200}, nil
}
The poller is not instant: poll_seconds defaults to 60, so a dropped object can take a full minute to reach Handle. A quiet minute after an upload is the trigger working, not a broken one.
Return a non-nil error on a trigger delivery and the shim answers 400, which the trigger treats as never retry. If the failure is temporary — a downstream service was down — do not return the error; log it and return nil so the delivery succeeds, or let the trigger retry by failing in a way that yields 5xx. Details: triggers.
Environment and secrets
Both arrive as environment variables; read them with os.Getenv. Env vars can be read back through the platform; secrets never can.
- platformctl
- curl
- Console
platformctl agents env set my-function LOG_LEVEL=debug
platformctl secrets set my-function EXTERNAL_API_KEY=abc123
curl -s -X PATCH "$CAI_API/v1/agents/my-function/secrets" \
-H "Authorization: Bearer ***" \
-H "Content-Type: application/json" \
-d '{"set": {"EXTERNAL_API_KEY": "abc123"}}'
On the function's page, Secrets and environment. Saving rolls a new revision.
Read at package level — env is fixed per revision, so a package-level var does the lookup once:
package main
import "os"
var apiKey = os.Getenv("EXTERNAL_API_KEY")
The platform injects its own variables alongside yours. The ones a Go function actually calls are CAI_API_URL, CAI_PUBSUB_URL and CAI_VECTORDB_URL — private addresses on the platform's own network, which resolve from inside your project and nowhere else, and which you read with os.Getenv rather than writing their values into your code — plus CAI_PROJECT_ID, CAI_PROJECT_KEY, MCP_SERVERS and CRUSOE_REQUEST_TIMEOUT_SECONDS. Exactly four 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 accepted and quietly discarded at deploy.
Key names must match ^[A-Za-z_][A-Za-z0-9_]*$. Full details: secrets and environment variables.
Project secrets: bind, then apply
A project secret is stored once and bound into many workloads, each under the variable name its own code expects. Recording a binding delivers nothing; apply is what reads the values, writes them onto the function, and rolls a revision so the running function sees them:
printf %s "$STRIPE_KEY" | platformctl secrets put stripe-key
platformctl secrets bindings set my-function STRIPE_KEY --secret stripe-key
platformctl secrets bindings apply my-function # <- this is what delivers it
In the console: Bind a secret on the function's page, then confirm the Apply bindings to my-function? dialog with its Apply to my-function button.
The ordering catches people. A deploy rolls its revision immediately, before any binding is applied, so a function deployed first and bound second has a first revision with no bound values on it — and in Go that is silent, because os.Getenv on an unset name returns "" rather than failing. Apply after the deploy (the apply rolls a fresh revision), or record the bindings before the first deploy, which picks them up. A package-level var that checks for the empty string is what turns this into an error you can read.
Invoke pattern
platformctl invoke my-function "some text"
The shared invoke path delivers your text as event["message"] and drops every other key. For arbitrary bodies, call the function's own public_url directly — the full body reaches Handle untouched:
curl -s -X POST "$FN_URL" \
-H 'Content-Type: application/json' \
-d '{"user_id": "u-123"}'
The NDJSON streaming variant (POST /v1/agents/{name}/invoke/stream) speaks the same contract as for agents — one JSON object per line. Details: invoke.
Logs and status
platformctl status my-function # building -> deploying -> ready
platformctl logs my-function # live only; empty at scale-to-zero
platformctl logs my-function --history # persisted lines
log.Printf lines land in logs. A run that scales back to zero leaves its history behind — that is usually the only way to see what a single trigger firing did.
Update (ship a new version)
platformctl functions deploy ./my-function --name my-function
Each deploy replaces the whole source tree and recompiles. A compile error in the new version fails the build and leaves the running revision untouched — a deploy either ships or does not, which is the behavior you want.
Calling other services
Stdlib net/http covers outbound HTTP with zero new dependencies — the stdlib-only constraint stops mattering for service calls:
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
var (
// Injected by the platform: where vectordb-api is, and which project this is.
vdbEndpoint = os.Getenv("CAI_VECTORDB_URL")
projectID = os.Getenv("CAI_PROJECT_ID")
// Your own credential, bound as a secret: a service-account key (cai_...).
vdbToken = os.Getenv("VECTORDB_TOKEN")
client = &http.Client{Timeout: 10 * time.Second}
)
func queryVectorDB(vector []float64) ([]map[string]any, error) {
body, _ := json.Marshal(map[string]any{"vector": vector, "top_k": 5})
url := fmt.Sprintf("%s/v1/projects/%s/indexes/docs:query", vdbEndpoint, projectID)
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+vdbToken)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out struct {
Results []map[string]any `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return out.Results, nil
}
Three things in that example are load-bearing:
- The
http.Clientis a package-level var: a cold start pays for one client, and warm instances reuse it. - The endpoint comes from
CAI_VECTORDB_URL. It is the injected name;VECTORDB_ENDPOINTis not, and in Go a name that is not set does not fail loudly —os.Getenvreturns"", theSprintfproduces a relative path,http.NewRequestaccepts it, and you find out per request, asPost "/v1/projects//indexes/docs:query": unsupported protocol scheme "". Package-scope reads are still right, because env is fixed per revision, but a typo surfaces at the first call rather than at startup. - The token is a service-account key you stored as a secret and bound to the function.
CAI_PROJECT_KEY, the identity the platform injects, will not do: its only capability is minting a short-lived token to read this project's secrets, and every project route answers it with404— the same answer a stranger gets, so nothing tells you the credential was the problem.
The query vector has to match the index exactly, or the call comes back 400 query vector has 1536 dimensions; index "docs" expects 4096. The platform's If you embed with the platform's qwen-embedding, create the index without naming a width and the two match automatically. See VectorDB.
Memory Store: a function dials its project's own instance directly, at connection.host on port 6379. The platform writes an egress rule for exactly that peer and that port, 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; a function is already inside. The Go constraint bites here rather than the network one: there is no Redis client in the standard library and this runtime cannot add a dependency, so speaking RESP over a net.Dial is the only route from a Go function. Endpoint and credential Secret: connect from workloads.
Pub/Sub (publish): plain POST to CAI_PUBSUB_URL at /v1/projects/{project_id}/topics/{topic}:publish, body {"messages": [{"text": "..."}]}, answering {"message_ids": [...]}. Use a service-account key stored as a project secret and bound to the function, for the same reason as above — CAI_PROJECT_KEY answers 404 here.
Firewall note: 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.
Common bugs
Build fails with go function requires a handler.go with a Handle(event) func. The directory had no handler.go, or the file declares the wrong package/signature. Fix the file; the build re-checks on the next deploy.
Build fails with go function: main.go is the runtime's own file - rename yours. The directory shipped a main.go. That name belongs to the shim — the server your Handle runs behind — so the build refuses it rather than overwriting the server or failing later with a duplicate func main. Rename yours to anything else (app.go, run.go); every other .go file in the directory is compiled as-is.
A helper file's function is undefined at build time. Every .go file at the top level of the function directory is compiled, but subdirectories are not copied into the build, so a helper under internal/ or lib/ is not there. Move the file up next to handler.go.
A third-party import fails the build. import "github.com/..." has no module to resolve against — the stdlib-only constraint is real today. Rewrite against net/http, encoding/json, crypto, database/sql (with a driver you cannot have — note the boundary) or pick another runtime.
body too large or unreadable (413). Go's wording for the 8 MiB cap differs from the other runtimes (body exceeds 8388608 bytes elsewhere). Same cap, same fix: send a reference, not the data.
Type assertion on a missing key. event["name"].(string) on a missing key returns "" with ok=false — safe. The panic comes from event["name"].(string) in the single-return-value form, which panics on a missing key. Always use the two-value form, as in every example above.
Returning an error for a CloudEvent kills redelivery. The shim turns a non-nil error into a 400, and the trigger treats any 4xx as terminal — the firing is gone forever. See the retry rules before you choose to return an error.
Compile errors fail the next deploy, not the current revision. The running revision keeps serving while the new build fails. Check platformctl logs my-function for the compiler output, fix, redeploy.