Skip to main content

Serverless API

This page documents every endpoint on serverless-api, the service behind scale-to-zero container services. It is a separate API service from agent-engine-api: set $CAI_SERVERLESS_API (not $CAI_API) — see the API overview for the port-forward recipe. In-cluster address: serverless-api.cai-system.svc.cluster.local:8080.

Conventions

  • Errors use the standard envelope {"error": "<message>", "request_id": "<id>"}. Unrouted paths render JSON 404/405 in the same envelope.
  • Auth: all project-scoped routes require Authorization: Bearer <credential> (session token or API key). The {projectID} path segment must be a UUID — anything else 404s. "Not your project" and "does not exist" are both 404 by design; 403 appears in exactly one place (DELETE, admin-only).
  • Body cap: request bodies are limited to 256 KiB.
  • Rate limit: 100 requests/sec, burst 200, per principal, per API replica. /healthz is exempt. (rps: 0 disables it.)
  • No database, no service: if the platform database is unreachable, every project-scoped route answers 503 — the platform database is not configured, so project access cannot be resolved.
  • 202 semantics: create and :set-traffic return 202 — accepted, not serving. Poll status.phase and status.traffic.

Route table

MethodPathAuthPurpose
GET/healthznoneHealth (always 200; degradation is in the body)
GET/v1/projects/{projectID}/servicesproject memberList services (paged)
POST/v1/projects/{projectID}/servicesproject memberCreate a service
GET/v1/projects/{projectID}/services/{name}project memberGet one service
PATCH/v1/projects/{projectID}/services/{name}project memberSparse update
DELETE/v1/projects/{projectID}/services/{name}project adminDelete (204)
GET/v1/projects/{projectID}/services/{name}/revisionsproject memberList revisions
POST/v1/projects/{projectID}/services/{name}:set-trafficproject memberReplace the traffic split
GET/v1/projects/{projectID}/services/{name}/logsproject memberTail logs (text/plain)
GET/v1/projects/{projectID}/services/{name}/specproject memberSanitized live YAML
GET/v1/projects/{projectID}/services/{name}/metricsproject memberLive instance/readiness readout
GET/v1/projects/{projectID}/triggersproject memberList triggers
POST/v1/projects/{projectID}/triggersproject memberCreate a trigger
GET/v1/projects/{projectID}/triggers/{name}project memberGet a trigger
PATCH/v1/projects/{projectID}/triggers/{name}project memberUpdate a trigger
DELETE/v1/projects/{projectID}/triggers/{name}project memberDelete a trigger

GET /healthz

Unauthenticated. Always returns 200, even when degraded — the degradation is in the body.

FieldValues
statusok | degraded
serviceserverless-api
databaseok | disabled | unreachable (+ database_detail)
sessionsok | unavailable (+ sessions_detail)
kubernetesok | unreachable (+ kubernetes_detail)
crdok | missing (+ crd_detail, naming serverlessservices.platform.crusoe.ai)

GET /v1/projects/{projectID}/services

Lists one page of services.

Query: page_size (default 50, max 200 — over-max is rejected, not clamped) and page_token (opaque, scope-bound cursor).

Response: 200 — {"services": [Service, ...], "next_page_token": "..."}. next_page_token is top-level and, on the last page, is omitted from the response entirely — it is not sent as "". Test for the key's presence, not for an empty-string value. The triggers list at /v1/projects/{projectID}/triggers behaves the same way.

note

This differs from the Agent Engine list endpoints (agents, sessions), which always emit next_page_token and use "" to mean "last page". See Agents API.

Errors:

  • 400 — page_size must be between 1 and 200
  • 400 — page_token is invalid or was issued for a different list; start from the first page

POST /v1/projects/{projectID}/services

Creates a service. Only name and image are required — everything else has a server-side default that is echoed back.

{
"name": "checkout-api",
"image": "localhost:30500/checkout-api:c5c2f25d",
"command": ["..."], "args": ["..."],
"env": {"LOG_LEVEL": "info"},
"env_from": [{"secret_name": "api-credentials", "config_map_name": "", "optional": true}],
"ports": [{"name": "h2c", "container_port": 8080}],
"resources": {"requests": {"cpu": "250m", "memory": "512Mi"},
"limits": {"cpu": "1", "memory": "512Mi"}},
"scaling": {"min_scale": 0, "max_scale": 10, "container_concurrency": 80},
"publish": {"enabled": false},
"traffic": [{"revision_name": "", "latest_revision": true, "percent": 100, "tag": ""}],
"runtime_class_name": "gvisor",
"service_account_name": "",
"timeout_seconds": 300
}
FieldTypeRequiredDefaultNotes
namestringyesDNS-1035, 1–52 chars: ^[a-z]([-a-z0-9]{0,50}[a-z0-9])?$. Cannot be renamed. (52, not 63: the platform appends -00001 for revision names.)
imagestringyesUse the pull prefix localhost:30500/<repo>:<tag>, a public registry, or CCR. The push endpoint registry.cai-system.svc.cluster.local:5000 is not pullable by nodes — using it fails minutes later with an image pull error.
command, argsstring[]noimage defaultsContainer entrypoint override.
envmapno{}Plain env vars (visible to anyone who can read the service; use env_from + Secrets for credentials).
env_fromarrayno[]{"secret_name", "config_map_name", "optional"} references.
portsarraynoport 8080Max one port; name must be http1 or h2c; container_port 1–65535.
resourcesobjectnorequests 250m/512Mi, limits 1 CPU/512MiDeliberately small so a bare create doesn't inherit the namespace LimitRange's 2 CPU / 4Gi default.
scaling.min_scaleintno0Instance floor.
scaling.max_scaleintno10Instance ceiling. 0 currently behaves as 10 (see note below).
scaling.container_concurrencyintno800 means the serverless default (effectively unbounded per instance).
publish.enabledboolnofalsetrue gives the service a public HTTPS URL: https://<name>-<project-short>.apps.codyhill.dev. publish.host is accepted and stored but ignored — there is no custom domain today.
trafficarrayno100% to latestThe complete split; validated as below.
runtime_class_namestringno"""gvisor" opts into the gVisor sandbox; the controller reports RuntimeClassApplied: False if the cluster stripped it.
service_account_namestringno""If unset, no Kubernetes service-account token is mounted into the container.
timeout_secondsintno3001–3600. Always mirrored into the env var CRUSOE_REQUEST_TIMEOUT_SECONDS.

Response: 202 Accepted (not 201 — the object exists but nothing is serving yet) with the full Service object (below).

Errors:

  • 400 — invalid JSON body: ...
  • 400 — name must be 1-52 characters of lowercase letters, digits and dashes, start with a letter and end with a letter or digit
  • 400 — image is required - a serverless service has nothing to run without one
  • Traffic validation: 400 — traffic percentages sum to N, not 100; traffic percent N is out of range 0-100; each traffic target needs either revision_name or latest_revision: true; traffic target "X" sets both revision_name and latest_revision; traffic target "X" appears twice; traffic tag "X" appears twice; revision X does not exist for this service
  • Scaling validation: 400 — min_scale N is negative...; max_scale N is negative...; container_concurrency N is negative; ... (0 means the serverless default); min_scale N is greater than max_scale M; ...
  • 409 — a service with that name already exists in this project
  • 400 — rejected by the cluster: ...
  • 500 — create service: ...
max_scale 0 is not "unlimited" here

The API accepts and round-trips max_scale: 0, but the platform operator currently coerces 0 (or a negative) to 10 when it configures autoscaling. Net effect today: max_scale: 0 behaves as max_scale: 10. If you need a higher ceiling, set it explicitly.

The Service object

Every read and write returns this shape:

{
"name": "...", "project": "projects/<short>/services/<name>",
"image": "...", "command": [], "args": [], "env": {}, "env_from": [], "ports": [],
"resources": {"requests": {}, "limits": {}},
"scaling": {"min_scale": 0, "max_scale": 10, "container_concurrency": 80},
"publish": {"enabled": false, "host": ""},
"traffic": [], "runtime_class_name": "", "service_account_name": "",
"timeout_seconds": 300, "created_at": "RFC3339",
"status": {
"url": "http://...",
"external_url": "https://<name>-<project-short>.apps.codyhill.dev",
"internal_url": "http://<name>.<namespace>.svc.cluster.local",
"published": false,
"latest_ready_revision": "name-00002", "latest_created_revision": "name-00002",
"traffic": [{"revision_name": "...", "percent": 100, "tag": "", "url": ""}],
"phase": "Pending",
"conditions": [{"type": "Ready", "status": "True", "reason": "...", "message": "...",
"last_transition_time": "..."}],
"observed_generation": 3, "generation": 3
}
}

Status fields worth knowing:

FieldMeaning
status.urlThe cluster URL the serving layer advertises.
status.external_urlThe public HTTPS address — filled in only once it is actually reachable over valid TLS.
status.internal_urlAlways-present in-cluster address.
status.phasePending | Ready | NotReady | Degraded | Invalid. Pending means the controller hasn't written status yet.
status.conditionsNamed checks behind the phase: Ready, ServiceReady, VisibilityEnforced, RuntimeClassApplied, TrafficAccepted, Exposed.
generation vs observed_generationUnequal means "your change was accepted but not yet applied".
Internal is not authenticated

"Internal" removes the service from the internet; it does not authenticate callers. Any workload that can reach the shared gateway can reach a cluster-local service. Put your own auth in front of anything sensitive. See Public endpoints and domains.

GET /v1/projects/{projectID}/services/{name}

200 with the Service object. 404 for both "doesn't exist" and "not your project" (deliberately indistinguishable).

PATCH /v1/projects/{projectID}/services/{name}

Sparse update: every field of the create body is accepted; omitted fields are left alone, and explicit zero values set zero. Exception: a scaling block, if present, replaces the whole block — omitted sub-fields inside it revert to the platform defaults (0/10/80). Traffic in a PATCH is validated exactly as on create.

Response: 200 with the updated object.

Errors: 409 — the service was modified concurrently - re-read it and retry (optimistic concurrency), plus all create-time validation errors.

Secrets do not hot-reload

Revisions are immutable. Updating a Secret referenced via env_from does not affect running revisions — you need a new revision (for example, a PATCH) to pick up the new value.

DELETE /v1/projects/{projectID}/services/{name}

Requires the project admin role — a plain member gets 403 (this is the one place the API uses 403). Deletes the endpoint and releases the public hostname claim.

Response: 204 No Content.

GET /v1/projects/{projectID}/services/{name}/revisions

Response: 200 — {"revisions": [Revision, ...]}. Revisions are returned whole, newest first (ordered by configuration generation, not by name), so there is never a next page and next_page_token is never present in the response.

Revision fields: name, generation, image (the resolved digest when known), ready ("True" | "False" | "Unknown"), reason, message, replicas, created_at, traffic_percent, tag, url.

404 if the service isn't in this project.

POST /v1/projects/{projectID}/services/{name}:set-traffic

Replaces the entire traffic split — it never merges. Percentages must sum to 100; named revisions must exist; latest_revision: true targets are allowed.

{"traffic": [{"revision_name": "checkout-api-00001", "percent": 90},
{"revision_name": "checkout-api-00002", "percent": 10, "tag": "canary"}]}

Response: 202 with the updated Service. Watch status.traffic for the live split.

Errors:

  • 400 — traffic is required - send the complete split, e.g. [{"revision_name":"api-00002","percent":100}]
  • The same traffic-validation 400s as create.
  • Any other custom verb → 404 — unknown method ':<verb>' - the supported one is ':set-traffic'

GET /v1/projects/{projectID}/services/{name}/logs

Streams text/plain (never JSON) from the newest pod only.

Query: ?tail=N (accepted range 1–10000; default 500) and ?follow=true to keep the connection open.

A scaled-to-zero service returns 200 with this body:

no running pods: this service is scaled to zero. Send it a request and the logs will appear here.

Mid-stream failures append a final line: [log stream ended: <reason>].

There is no platformctl command for serverless logs — use this endpoint or the console's Logs tab.

GET /v1/projects/{projectID}/services/{name}/spec

The "YAML tab". Returns the live object as sanitized YAML: metadata.managedFields and the kubectl last-applied annotation are stripped, and any spec.env value whose key matches (?i)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE) is replaced with ***REDACTED*** (the key is kept). The output never contains a secret value.

Response: 200 — {"service": "projects/<short>/services/<name>", "yaml": "...", "note": "..."}

GET /v1/projects/{projectID}/services/{name}/metrics

A live, point-in-time readout — there is no time-series database, so no historical charts, and the note field says so plainly.

Response: 200

{"service": "...",
"instances": {"current": 1, "desired": 1},
"revisions": [{"name": "...", "generation": 2, "replicas": 1, "ready": "True",
"traffic_percent": 100, "latest_ready": true, "latest_created": true}],
"phase": "Ready",
"readiness": {"ready": "True", "reason": "", "message": "", "last_transition_time": "..."},
"scaling": {"min_scale": 0, "max_scale": 10, "container_concurrency": 80},
"note": "..."}

desired equals current on this API.

Triggers

A trigger fires a service without an inbound HTTP call. Triggers target a service by name within the same project only — there is no cross-project field, so cross-tenant invocation is structurally impossible. The target may be a serverless service or a bare platform service (a function or agent).

MethodPathPurpose
GET/v1/projects/{projectID}/triggersList triggers
POST/v1/projects/{projectID}/triggersCreate (validates the target exists; returns 202)
GET/v1/projects/{projectID}/triggers/{name}Get one
PATCH/v1/projects/{projectID}/triggers/{name}Update
DELETE/v1/projects/{projectID}/triggers/{name}Delete

Trigger sources:

SourceWhat it does
scheduleCron schedule (runs as a platform CronJob).
objectstoreFires on object-storage activity (S3 polling).
pubsubFires when a message is published to a Pub/Sub topic in the same project. The platform creates a push subscription for the trigger and delivers each message to the target as a CloudEvent. The trigger sits at Pending only until the topic and its subscription become ready, then reports Ready.

Errors: 409 — a trigger with that name already exists in this project