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
- File:
handler.go, withpackage main, exporting exactly this signature:
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.GETproduces an empty map; aPOST's JSON body becomes the map. It has to be a JSON object — an array, a scalar, or anything that is not JSON at all is answered400 {"error": "invalid JSON body: ..."}beforeHandleruns, because the shim decodes straight intomap[string]any. - Return:
(map[string]any, error). The map becomes the JSON response body; include a"statusCode"key to set the HTTP status (default200). 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
One file is the whole scaffold — there is no dependency file:
my-function/
└── handler.go
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
}
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 decodes that body into the map[string]any your handler takes, so this runtime only works on buckets of JSON objects. A markdown file, a CSV, a log line, or even a JSON array fails json.Unmarshal and the shim answers 400 {"error": "invalid JSON body: ..."} before Handle is called. The bucket poller reads no retry block — it delivers once — so that object is gone, and nothing reaches your logs because your code never ran. For markdown, CSV or binary objects use the Python runtime, whose shim hands a non-JSON body to the handler as event["data"].
The key does not arrive either, and the Go shim passes no CloudEvent attributes to Handle — there is no _cloudevent entry as there is in Python — so identity has to come from inside the object:
func Handle(event map[string]any) (map[string]any, error) {
// The body IS the object: these fields are the dropped 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.
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.