Skip to main content

Ruby functions

Ruby is the quiet runtime: one handler.rb, an optional Gemfile, and a handler contract identical to Python's. This page is the complete guide — scaffold, deploy, every trigger type, secrets, invoking, logs, updating, calling other services, and the handful of Ruby-specific bugs.

The contract

  • File: handler.rb, defining a top-level handle(event) method. The shim calls it directly — no async machinery, no framework.
  • Event: one argument, a Hash with string keys. GET produces {}; a POST's JSON body becomes the Hash.
  • Return: a Hash. The whole thing becomes the JSON response body. The status key is the string 'statusCode', not a symbol — see common bugs.
  • Dependencies: an optional Gemfile next to handler.rb. Bundler installs the gems at build time.

Scaffold

my-function/
├── handler.rb
└── Gemfile # optional

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

def handle(event)
name = (event['name'] if event.is_a?(Hash)) || 'world'
{ 'statusCode' => 200, 'body' => "hello, #{name}, from a Ruby function" }
end

Deploy

The runtime is auto-detected from handler.rb:

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

You should see the upload line name the detected runtime:

packaging ./my-function...
uploading my-function (1.1 KiB, framework=function, runtime=ruby)...

The line names the function, not the directory — it is the --name you passed.

A cold-cache Ruby build runs Bundler and can cross the CLI's five-minute wait. Raise it with CAI_DEPLOY_TIMEOUT if you hit timed out after 5m0s waiting for ... (last state: building) — the build itself has not necessarily failed.

Per-trigger-type handlers

HTTP

def handle(event)
user_id = event.is_a?(Hash) ? event['user_id'] : nil
return { 'statusCode' => 400, 'error' => 'user_id is required' } unless user_id
{ 'statusCode' => 200, 'user' => user_id }
end

Scheduled (cron)

CloudEvent rules: the shim ACKs 204 and discards the return value. puts is how you observe it:

require 'json'

def handle(event)
job = (event['job'] if event.is_a?(Hash)) || 'default'
puts "scheduled run: #{job}"
# ... do the work ...
{ 'statusCode' => 200, 'ok' => true } # discarded; keep it for direct HTTP calls
end

Pub/Sub

def handle(event)
puts "processing order #{event['order_id'] if event.is_a?(Hash)}"
# ... process ...
{ 'statusCode' => 200 }
end

ObjectStore (bucket events)

An object-store trigger delivers the object's own bytes as the request body. There is no JSON description wrapped around it: no bucket key, no object, no key. The Ruby shim parses that body as JSON, and a body that is not JSON gets a 400 whose whole content is {"error":"invalid JSON body: ..."} — returned before handle is ever called. The bucket poller reads no retry block; it delivers once, so that object is gone. platformctl logs shows nothing at all, not even the usual handler raised: line, because the handler never ran.

So point Ruby at buckets of JSON objects. For markdown, CSV or anything else, use Python — its shim hands a non-JSON body to the handler as event['data'].

def handle(event)
# The body IS the object, parsed. There is no 'bucket' or 'object' key.
puts "ingested record #{event['id']}"
{ 'statusCode' => 200 } # discarded; a CloudEvent delivery ACKs 204
end

The delivery does not name the object either. Its CloudEvent subject is the literal string aws-s3-source — the name of the source, not a filename — and the Ruby shim passes no CloudEvent attributes to the handler at all. If a record needs an identity, put it inside the object. Polling interval, after_read and the credentials secret: ObjectStore triggers.

Environment and secrets

Both arrive as environment variables; read them with ENV. Env vars can be read back through the platform; secrets never can — only their key names.

platformctl agents env set my-function LOG_LEVEL=debug
platformctl secrets set my-function EXTERNAL_API_KEY=abc123

Read at the top level, outside the handler — env is fixed per revision, so a constant does the lookup once:

API_KEY = ENV['EXTERNAL_API_KEY'].freeze

def handle(event)
return { 'statusCode' => 500, 'error' => 'EXTERNAL_API_KEY is not configured' } unless API_KEY
# ...
end

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 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. Apply after the deploy (the apply rolls a fresh revision), or record the bindings before the first deploy, which picks them up. ENV['STRIPE_KEY'] is nil on that first revision and the constant assigns happily — ENV.fetch('STRIPE_KEY') raises key not found at load instead, which fails the revision loudly and puts the reason in platformctl logs my-function --history.

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 the handler 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

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

Update (ship a new version)

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

Each deploy replaces the whole source tree and rebuilds in the same runtime. In the console, use Update source.

Calling other services

net/http is in the standard library and covers outbound HTTP with zero gems. Credentials belong in secrets — read them from ENV.

The platform injects the addresses of its own services, so a handler never has to hardcode one. 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, so read it from ENV at every start rather than pasting a value into your code. CAI_PROJECT_ID, CAI_PROJECT_KEY, MCP_SERVERS and CRUSOE_REQUEST_TIMEOUT_SECONDS arrive with them, and 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, and quietly discarded at deploy.

CAI_PROJECT_KEY is the workload's own identity and the project APIs refuse it — its one power is minting a short-lived token to read this project's secrets — so a function that publishes to Pub/Sub needs a service account key of its own, stored as a project secret, bound to the function, and read from ENV.

Use ENV.fetch for a name the platform injects. ENV['CAI_VECTORDB_URL'] on a missing name is nil: the constant assigns happily, URI("#{nil}/v1/...") builds a URI with no host, and the failure arrives one request later inside Net::HTTP.start. ENV.fetch raises key not found: "CAI_VECTORDB_URL" at load instead, so the revision fails to start and platformctl logs my-function --history says why.

require 'net/http'
require 'json'
require 'uri'

VDB_URL = ENV.fetch('CAI_VECTORDB_URL') # injected
PROJECT = ENV.fetch('CAI_PROJECT_ID') # injected
VDB_TOKEN = ENV['VECTORDB_TOKEN'] # your own service account key, bound as a secret

def query_vectordb(vector)
# A custom method: the verb is a :suffix on the index, not a path segment.
uri = URI("#{VDB_URL}/v1/projects/#{PROJECT}/indexes/docs:query")
req = Net::HTTP::Post.new(uri)
req['Authorization'] = "Bearer #{VDB_TOKEN}"
req['Content-Type'] = 'application/json'
req.body = JSON.generate({ vector: vector, top_k: 5 })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(req)
end
JSON.parse(res.body)['results'] # 'results', not 'points'
end

The query vector's length must match the index exactly, or the call comes back 400 query vector has 1536 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.

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 is for reaching the instance from outside the platform — from your laptop, say; a function is already inside. Declare a Redis client in your Gemfile, and pass the host and the password from ENV; both come from 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": [...]}. Send the service account key described above as Authorization: BearerCAI_PROJECT_KEY answers 404 not found on a topic you can see in the console, because an inaccessible project is refused as 404 rather than 403. See the Pub/Sub API.

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

:statusCode is not 'statusCode'. The shim reads the string key. Return { statusCode: 404 } with a symbol and the HTTP status stays 200 while a stray statusCode field sits in the body — the two bugs most reported against this runtime, in one line. Write { 'statusCode' => 404, ... }. Every example above already does.

Event keys are strings, not symbols. The same JSON-parse behavior applies to the incoming event: read event['user_id'], never event[:user_id]. Guard with event.is_a?(Hash) for a GET that arrived with no body — the examples above show the idiom.

Bundle install time. A cold cache plus a Gemfile with native extensions can cross the CLI's five-minute deploy wait, ending in timed out after 5m0s ... (last state: building). Raise CAI_DEPLOY_TIMEOUT, and check platformctl status my-function before assuming failure — a slow build is still building.

Missing gem at load fails every request identically. A require for a gem not in the Gemfile fails the module load (not one request) with LoadError, so every call returns the same error. Check platformctl logs my-function --history for the cannot load such file line.

Stray handler file changes the detected runtime. A leftover handler.rb in a directory you meant as Python flips detection to Ruby. Read the upload line — it names the runtime picked.