Skip to main content

Serverless API

This page documents every endpoint on the serverless API, the service behind scale-to-zero container services. It is a separate API service from the Agents API, so it reads its own variable: set $CAI_SERVERLESS_API (not $CAI_API) to https://api.codyhill.dev, the public hostname that serves both — see the API overview.

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 until ready is true; state and active_traffic tell you where it has got to.

Endpoints at a glance

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

Health

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)
orchestrationok | unreachable (+ orchestration_detail) — the platform's own orchestration layer
crdok | missing (+ crd_detail, naming serverlessservices.platform.crusoe.ai)

Create and list services

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 Agents API 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": "registry.us-east1-a.ccr.crusoecloudcompute.com/cai-ab12cd-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": "",
"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.)
imagestringyesA public registry reference, or a CCR one (see the CCR warning below). An image the platform built for you from source is already a CCR reference.
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 service created without asking for resources doesn't inherit the project's much larger 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""Asks for a stricter isolation runtime by name. If the platform accepts it, the service reports RuntimeClassApplied: True; if it does not, the value is not applied and the condition reports False with the reason. Ask your administrator which names, if any, your environment offers.
service_account_namestringno""If unset, no platform identity token is mounted into the container. Leave it unset for CCR images — see the warning below.
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 platform: ...
  • 500 — create service: ...
max_scale 0 is not "unlimited" here

The API accepts and round-trips max_scale: 0, but the platform 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.

Images from Crusoe Container Registry (CCR)

An image whose host ends in .ccr.crusoecloudcompute.com (or contains .container-registry.crusoecloud.) is a CCR image, and pulling one has two preconditions the API cannot check for you. Miss either and the create still answers 202 — the failure arrives minutes later as an image pull error, visible only as a ready that never turns true and a message naming the pull failure.

  1. The project must be mapped to Crusoe Cloud. Mapping (Project Settings in the console, or PUT /v1/projects/{projectID}/crusoe-cloud on the platform API) writes a hidden image-pull credential into your project. Without that credential, nothing in the project can pull from CCR. See known issues for two sharp edges around it: it is built only during the mapping call, and its token expires.
  2. service_account_name must be empty. The API attaches the CCR credential through an identity it owns. It will not override a service_account_name you set yourself, on the reasoning that an identity you chose owns its own pull credentials. So setting service_account_name on a CCR image silently removes the pull credential.

Neither rule touches a public image such as docker.io/library/nginx, which is left completely alone.

Setting service_account_name breaks CCR pulls

If your image is on CCR, leave service_account_name unset. There is no error and no warning — the deploy is accepted, the revision comes up without a pull credential, and the pull fails.

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",

"state": "pending", "ready": false, "message": "waiting for the first instance",
"url": "http://...",
"external_url": "https://<name>-<project-short>.apps.codyhill.dev",
"internal_url": "http://<private-hostname>",
"published": false,
"latest_ready_revision": "name-00002", "latest_created_revision": "name-00002",
"active_traffic": [{"revision_name": "...", "percent": 100, "tag": "", "url": ""}],
"conditions": [{"type": "Ready", "status": "True", "reason": "...", "message": "...",
"last_transition_time": "..."}],
"observed_generation": 3, "generation": 3
}

Everything the platform observed about the service sits at the top level, beside what you asked for — there is no status wrapper. Fields worth knowing:

FieldMeaning
statepending | ready | not_ready | degraded | invalid. pending means nothing has reported on the service yet. Show this to a human.
readyBoolean, and it means here exactly what it means on every other resource: can I use this right now. Branch on this.
messageWhy it is not ready, in the platform's own words. Omitted once it is.
urlThe address the serving layer advertises.
external_urlThe public HTTPS address — filled in only once it is actually reachable over valid TLS.
internal_urlAlways-present internal address, callable only from workloads running on the platform.
traffic vs active_traffictraffic is the split you requested; active_traffic is the split actually being served. During a rollout they differ, and that gap is the rollout.
conditionsNamed checks behind the state: Ready, ServiceReady, VisibilityEnforced, RuntimeClassApplied, TrafficAccepted, Exposed. Each carries its own status of "True" / "False" / "Unknown" — that is the check's verbatim answer, not the platform-wide ready boolean.
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 private service. Put your own auth in front of anything sensitive. See Public endpoints and domains.

Manage one service

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. Traffic in a PATCH is validated exactly as on create.

The scaling block is sparse too, and it merges onto the service's current values — not onto the platform defaults. Send only what you want to change:

curl -sS -X PATCH "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/services/checkout-api" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"scaling": {"min_scale": 2}}' | jq .scaling

You should see (assuming the service was already at max_scale: 25):

{
"min_scale": 2,
"max_scale": 25,
"container_concurrency": 80
}

max_scale and container_concurrency keep whatever they were. You never need to resend a field defensively to protect it.

The flip side: you cannot reset a scaling field by omitting it. Omitting means "leave it alone", so to put max_scale back to the platform default of 10 you have to send "max_scale": 10 explicitly. (Create is the opposite — there is no current value to merge onto, so an omitted field there does take the platform default.)

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.

Revisions and traffic

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), state (ready | not_ready | unknown), ready (boolean), reason, message, replicas, created_at, traffic_percent, tag, url.

ready is a boolean here, exactly as it is on every other resource, and state carries the tri-state a revision genuinely has: unknown is "nothing has reported on this one yet", which is not the same answer as not_ready. Branch on ready; show state, and read reason / message for the runtime's own words about a revision that will not come up.

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. The split you just sent is echoed back as traffic; watch active_traffic for the split actually being served. They converge when the rollout lands.

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'

Logs, spec, and metrics

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

Streams text/plain (never JSON) from the newest running instance 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 instances: 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>].

platformctl serverless logs <name> calls this endpoint (-f to follow, --tail for history), and the console's Logs tab shows the same stream.

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

An API-only debugging aid - the console no longer has a YAML tab. What this returns is the engine underneath your Serverless Workload, which is not part of the platform interface and may change; the console does not show it, because nothing you can act on lives only there. Returns the live object as sanitized YAML: the platform's internal bookkeeping fields 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, "state": "ready", "ready": true,
"traffic_percent": 100, "latest_ready": true, "latest_created": true}],
"state": "ready",
"ready": true,
"readiness": {"state": "ready", "ready": true, "reason": "", "message": "", "last_transition_time": "..."},
"scaling": {"min_scale": 0, "max_scale": 10, "container_concurrency": 80},
"note": "..."}

desired equals current on this API.

state and ready here are the same two fields, spelled the same way, that the Service object carries — this view just repeats them beside the live counts. readiness adds the Ready check's own reason, message and last_transition_time, which is how you answer "how long has it been like this" without a time-series database.

Triggers

A trigger fires a workload without an inbound HTTP call — on a clock, on a Pub/Sub message, or when an object lands in a bucket. The platform POSTs to the workload's internal address; you never expose anything to the internet to get a trigger working.

For a task-shaped walkthrough of all three source types, see triggers.

Triggers name their target by name, within the same project only. There is no cross-project field anywhere in the request, so a trigger in one project cannot invoke another project's workload — that is a property of the shape of the API, not a validation rule.

All three deployable things are triggerable. The API resolves the target name as a serverless service first, and falls back to the plain workload that functions and agents deploy as. So "run this agent every night at 2am" and "fire this function on a Pub/Sub message" are both supported — see target.path for the one extra field they need.

The console's picker is the narrow one

platformctl serverless triggers create|update|delete|get|list wraps these routes, and its --target takes any workload name. The console's Triggers tab creates them too, but its target picker lists only serverless services — so a trigger that fires a function or an agent has to be created here, on the REST API, or from the CLI.

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 (the only route that returns firing history)
PATCH/v1/projects/{projectID}/triggers/{name}Update
DELETE/v1/projects/{projectID}/triggers/{name}Delete (204)

POST /v1/projects/{projectID}/triggers

Creates a trigger. This example runs an agent every night at 2am New York time:

{
"name": "nightly-digest",
"target": {"service": "research-buddy", "path": "/invoke"},
"source": {
"type": "schedule",
"schedule": {
"cron": "0 2 * * *",
"time_zone": "America/New_York",
"payload": "{\"message\": \"Summarize today's papers.\"}"
}
},
"retry": {"max_attempts": 5, "backoff": "exponential"}
}
FieldTypeRequiredDefaultNotes
namestringyesSame rule as a service name: 1–52 characters, lowercase letters, digits and dashes, starting with a letter.
target.servicestringyesThe name of a serverless service, function, or agent in this project. It must already exist — a name that resolves to nothing is a 404 at create time, not a trigger that quietly never fires.
target.pathstringno/The sub-path the trigger POSTs to. Set this to /invoke for an agent. See below.
source.typestringyesschedule, pubsub, or objectstore. Selects which of the three source blocks is read.
source.scheduleobjectfor scheduleSee schedule source.
source.pubsubobjectfor pubsubSee Pub/Sub source.
source.objectstoreobjectfor objectstoreSee object-store source.
retry.max_attemptsintno5How many delivery attempts one firing gets. Accepted range 1–20; outside it the platform rejects the object and you get a 400.
retry.backoffstringnoexponentialexponential, linear, or none.
suspendboolnofalsetrue creates the trigger without letting it fire. See suspend.

Response: 202 Accepted with the full Trigger object. 202, not 201, for the same reason as a service create: the object exists, but the machinery behind it (a timer, a subscription, a bucket poller) is still coming up.

target.path: the field that makes agents and functions work

A trigger POSTs to the target's internal address plus target.path. The default is /, and an agent does not serve anything at / — its entry point is POST /invoke. A trigger pointed at an agent without target.path therefore delivers to / on every tick, gets a 404 every time, and the only trace is a failed run you have to go looking for.

TargetSet target.path to
Agent/invoke
Function/ (the default) — the function shim answers at the root
Serverless servicewhatever path your container serves

A leading slash is added for you if you leave it off, and the path is appended to the resolved internal URL. The result is echoed back in delivery_url, so you can check it after creating the trigger.

The Trigger object

Every read and write returns this shape:

{
"name": "nightly-digest",
"project": "projects/<short>/triggers/nightly-digest",
"target": {"service": "research-buddy", "path": "/invoke"},
"source": {
"type": "schedule",
"schedule": {"cron": "0 2 * * *", "time_zone": "America/New_York", "payload": "..."}
},
"retry": {"max_attempts": 5, "backoff": "exponential"},
"suspend": false,
"created_at": "RFC3339",
"state": "ready",
"ready": true,
"message": "",
"delivery_url": "http://<private-hostname>/invoke",
"conditions": [{"type": "Ready", "status": "True", "reason": "...", "message": "...",
"last_transition_time": "..."}],
"observed_generation": 2,
"last_fired_at": "RFC3339",
"last_outcome": "succeeded",
"consecutive_failures": 0,
"runs": [{"instance": "...", "outcome": "succeeded", "started_at": "...",
"finished_at": "...", "message": ""}],
"runs_truncated": false
}

As on a service, everything observed sits at the top level — there is no status wrapper to reach through.

FieldMeaning
statepending | ready | not_ready | suspended. See the table below.
readyBoolean, meaning what it means everywhere: will this fire. Branch on it.
messageWhy it will not, when it will not. Omitted when it will.
delivery_urlThe fully-resolved internal address this trigger posts to, target.path included. Always the internal address — a trigger fires from inside the platform, so a private workload stays triggerable and the traffic never touches the internet.
conditionsThe named checks behind the state: TargetResolved, SourceReady, Ready, and RedeliveryControlled (object-store triggers only).
observed_generationThe version of your spec the controller has acted on. Lagging behind means "accepted, not yet applied".
last_fired_at, last_outcome, consecutive_failuresFiring history — see did my trigger fire?
runs, runs_truncatedPer-firing detail. Returned by the single-trigger GET only, never by the list.

Trigger states and conditions

StateWhat it meansWhat to do
pendingWaiting on something that is expected to show up: the target doesn't exist yet, or a Pub/Sub topic or bucket poller is still coming up. The platform keeps retrying without backing off.Usually nothing — check again in a few seconds.
readyWired up. It will fire.
not_readyA real failure the platform cannot retry its way out of.Read conditions for the reason.
suspendedsuspend: true. Set regardless of the conditions underneath.Set suspend: false to resume.

The two halves of a trigger fail independently, which is exactly why there are separate conditions:

ConditionFalse means
TargetResolvedThe workload this trigger fires cannot be found in this project.
SourceReadyThe machinery that produces events is not in place — a rejected cron expression, a bucket poller that isn't running, a subscription that isn't ready.
ReadyEither of the above is False.
RedeliveryControlledObject-store only: this trigger will fire again on objects it has already delivered. See after_read.

A trigger stuck at pending because its target doesn't exist and one stuck at not_ready because its cron expression was rejected need completely different fixes, and the condition list is where that difference lives.

Source: schedule

Fires on a cron schedule. The platform runs the timer for you, inside your project.

FieldTypeRequiredDefaultNotes
cronstringyesFive-field cron, or a descriptor. Rules below.
time_zonestringnoUTCAn IANA time zone name, such as America/New_York or Europe/Berlin. Without it your schedule runs in UTC, which is up to a day off from what you meant.
payloadstringno{}The exact request body delivered on each firing, as a JSON string. For an agent, that is {"message": "..."}.

What a scheduled firing actually sends

One POST to delivery_url, with Content-Type: application/json, the body set to payload verbatim, and CloudEvents 1.0 headers alongside it (binary content mode — the payload stays the body, the envelope goes in headers):

ce-specversion: 1.0
ce-id: <random hex>
ce-source: //platform.crusoe.ai/projects/<project>/triggers/<trigger-name>
ce-type: ai.crusoe.trigger.schedule
ce-time: <RFC3339>
ce-deliveryattempt: 1

Because the payload is the body unchanged, a scheduled trigger works against an agent's /invoke and against a function's handler without either of them knowing anything about CloudEvents.

Cron rules the API enforces

The cron expression is parsed when you create the trigger, not later — so a bad one is a 400 you can read, rather than a trigger that silently never fires.

RuleDetail
Exactly five fieldsminute hour day-of-month month day-of-week. A six-field expression with seconds is a different dialect and is rejected.
Field rangesminute 0–59, hour 0–23, day-of-month 1–31, month 1–12, day-of-week 0–6.
Sunday is 0, never 70 0 * * 7 is rejected. This is the single most common thing carried over from crontab(5) that does not work here.
NamesMonth and day names work: JANDEC, SUNSAT. 0 0 * * MON-FRI is fine.
?Accepted as a synonym for *.
Steps*/15 and 5/10 work. The step must be 1 or more.
RangesMust not run backwards — ranges do not wrap. Write two comma-separated entries instead.
Descriptors@yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly, and @every plus a Go duration such as @every 1h30m.

Real rejection messages:

the day of week field has 7, outside the allowed 0-6 - Sunday is 0 here, not 7, unlike crontab(5)
cron must have exactly 5 fields (minute hour day-of-month month day-of-week), got 6 in "0 0 1 * * *". A 6-field expression with seconds is a different dialect and is not accepted here
the hour field has 24, outside the allowed 0-23
"@fortnightly" is not a cron shorthand. The accepted ones are @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly and @every <duration>

Two behaviors of the schedule worth knowing

  • Overlapping firings are skipped, not stacked. If a delivery is still running when the next tick arrives, the next tick is dropped. A slow target never accumulates a pile of deliveries all posting the same event.
  • Missed windows are skipped, not caught up. If the platform was unable to run your 3am firing, it does not fire four hours of backlog at once when it recovers. "Every hour" means every hour, not "eventually all of them".

Source: pubsub

Fires when a message is published to a Pub/Sub topic in the same project.

FieldTypeRequiredDefaultNotes
topicstringyesA topic in this project. If it doesn't exist yet the trigger sits at pending with no topic named "..." in this project.
subscriptionstringnothe trigger's own nameThe durable subscription the platform creates and owns. Two triggers on one topic get their own cursors rather than competing for one.

The platform creates that subscription for you with these settings, derived rather than configurable:

SettingValueWhy it matters to you
Start positionnewMessages published before the trigger existed are never delivered. Create the trigger first, then publish.
TypesharedSeveral instances of a scale-to-zero target can consume in parallel.
Max deliveriesyour retry.max_attempts (default 5)The trigger's retry setting is the subscription's redelivery limit.
Ack deadline30 secondsA target that takes longer than 30 s to answer will see the message redelivered.
Max unacknowledged1000The ceiling on in-flight messages.
Deliverypush, content mode cloudevents-structuredThe request body is a CloudEvents envelope, not your published payload.
start_from: new — publish after you wire it up

The classic failure: create a topic, publish a test message, then create the trigger, then wonder why nothing fired. The subscription starts at new, so that message is already in the past. Publish again after the trigger reports ready.

A Pub/Sub trigger cannot drive an agent's /invoke today

Push delivery uses structured content mode, which makes the request body a CloudEvents envelope. An agent's /invoke accepts its own schema ({"message": "..."}) and rejects anything else, and the API has no field for choosing binary mode — so a Pub/Sub trigger aimed at /invoke will deliver and never succeed.

Two things that do work today: a schedule trigger against /invoke (its body is your payload, unchanged), or a Pub/Sub trigger aimed at a function or serverless service that unwraps the envelope and calls the agent itself.

If a subscription of that name already exists and this trigger does not own it, the trigger goes not_ready rather than hijacking it, with the collision named:

a subscription named "orders" already exists in this project and is not managed by this trigger; give the trigger a different source.pubsub.subscription, or delete that subscription

Source: objectstore

Fires when objects appear in an S3-compatible bucket. The platform polls the bucket — it does not need bucket notifications configured — and it does not host the object store, so you supply the endpoint and the credentials.

FieldTypeRequiredDefaultNotes
bucketstringyesThe source bucket.
prefixstringno""Only objects under this prefix fire the trigger.
eventsstring[]no["created"]Polling can only see objects it can read, so created is the meaningful value; a deletion is not observable by a poll.
endpointstringyesThe S3 endpoint URL. There is no default — the platform cannot guess where your object store is.
regionstringnous-east-1
credentials_secret_namestringyesThe name of a secret holding the S3 credentials. It must be a secret in this project, which is what stops a trigger reading another project's bucket. Credentials are never inline, so they never appear in the trigger object.
after_readstringyes — no defaultmove, delete, or none. What happens to an object once it has fired the trigger. Read the warning below before choosing.
move_toobjectwhen after_read is move{"bucket": "...", "prefix": "..."}. bucket defaults to the source bucket, so a prefix alone gives you the usual "processed/" arrangement.
poll_secondsintno60How often the bucket is listed. Minimum 1.
max_messages_per_pollintno10Bounds one poll, so a bucket that gains ten thousand objects at once does not become ten thousand simultaneous invocations. Range 1–1000.
force_path_styleboolnotrueAddresses buckets as endpoint/bucket rather than bucket.endpoint. Virtual-host addressing needs wildcard DNS and a matching certificate, which most S3-compatible stores don't have — and that failure looks like a network problem, so the safe form is the default.
after_read is required, and "none" re-fires forever

The poller tracks its progress by consuming objects, not by remembering a cursor. If nothing retires an object after it fires the trigger, the next poll reads it again — and fires again, every poll_seconds, forever.

  • move — copy to move_to, then remove from the source prefix. Keeps the data. The usual choice.
  • delete — destructive, and says so.
  • none — the object stays put and fires on every poll. Legitimate for a target that is safe to call repeatedly; a disaster for one that sends email or costs money.

after_read has no default because every possible default is wrong for somebody. Choosing none is allowed and honest — the trigger then reports RedeliveryControlled: False with the bucket named, so nobody discovers it from a billing alert.

Retry and delivery

retry governs one firing's delivery attempts — not how often the trigger fires. What it controls depends on the source:

SourceWhat retry does
scheduleDirectly: the platform makes up to max_attempts HTTP attempts per firing, spaced by backoff.
pubsubmax_attempts becomes the subscription's redelivery limit. backoff does not apply — redelivery is the subscription's, on its ack deadline.
objectstoreNot applied. The bucket poller re-reads and re-delivers on its own schedule.

Backoff, for schedule triggers:

BackoffDelay before attempts 2, 3, 4, 5
exponential (default)1s, 2s, 4s, 8s — doubling, capped at 60 s
linear2s, 4s, 6s — capped at 60 s
noneno delay

What is and is not retried, for schedule triggers:

  • Retried: transport errors, 429, and any 5xx. On a scale-to-zero platform the first attempt routinely races a cold start, so this matters.
  • Not retried: any other 4xx. A malformed request will be malformed on the next attempt too, and retrying it five times only piles load onto a target that already said no. A 404 from a missing target.path fails immediately, every time.

A run is marked failed only after all attempts are exhausted, so consecutive_failures: 1 means five failed deliveries at the default setting, not one.

Suspend a trigger

suspend: true stops a trigger firing without deleting it — what you reach for when a trigger is hammering a broken target and you do not want to lose the configuration.

curl -sS -X PATCH "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers/nightly-digest" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"suspend": true}' | jq '.suspend, .state'

You should see:

true
"Suspended"

What suspend does per source type:

SourceSuspended behavior
scheduleThe timer is paused, not deleted, so its firing history survives. Ticks that pass while suspended are gone for good.
pubsubThe subscription is switched to pull mode rather than deleted, which preserves the cursor. The backlog keeps accumulating, and resuming drains all of it.
objectstoreThe state reads suspended, but the bucket poller is not stopped on the platform — it keeps polling and keeps firing. Delete the trigger if you need polling to stop.
A suspended Pub/Sub trigger is still collecting work

Suspending is not the same as turning the tap off. Messages keep piling up in the subscription, and the moment you set suspend: false they are all delivered. If you suspended a trigger because the target was falling over, fix the target before you resume, or delete the trigger instead.

Did my trigger fire?

This is the question state does not answer — a trigger can sit at ready while every single firing 404s. The firing history does answer it.

curl -sS -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers/nightly-digest" \
| jq '{state, last: .last_outcome, fails: .consecutive_failures, runs}'

You should see:

{
"state": "ready",
"last": "failed",
"fails": 3,
"runs": [
{
"instance": "trigger-nightly-digest-29387460-x7k2p",
"outcome": "failed",
"started_at": "2026-08-12T02:00:04Z",
"finished_at": "2026-08-12T02:00:36Z",
"message": "giving up after 5 attempts: http 404 (not retryable): ..."
}
]
}
FieldMeaning
last_fired_atWhen the newest run started.
last_outcomesucceeded, failed, running, or pending. Empty for a trigger that has never fired.
consecutive_failuresCounts back from the newest run and stops at the first success — a live problem, not a lifetime total.
runs[]One entry per retained firing: instance, outcome, started_at, finished_at, and message (the failure text; empty for a success).
runs_truncatedtrue when more firings exist than were returned.

Three limits to know:

  1. runs is on the detail route only. The list route returns last_fired_at, last_outcome, and consecutive_failures, but omits runs on purpose — four runs times fifty triggers is a payload nobody reads.
  2. The window is short: one success and three failures. The platform discards older runs. This is "did it fire, and did it work", not a log. For the target's own output, read the workload's logs.
  3. Object-store triggers report no history at all. Their poller runs continuously instead of once per firing, so there is nothing per-firing to count — and reporting a misleading zero would be worse than reporting nothing.

If the history is briefly unavailable, the request still succeeds without those fields rather than failing — a trigger page that won't render because a supplementary read failed is worse than one that renders without the "last fired" column.

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

Sparse update. Send only target, source, retry, or suspend; anything you omit is untouched. Each block you do send replaces that block whole, so a source in a PATCH must be complete, and a new target is re-checked for existence exactly as on create.

Response: 200 with the updated Trigger.

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

Deletes the trigger and the machinery behind it — the timer, the subscription, or the bucket poller. Unlike deleting a service, this needs only the member role.

Response: 204 No Content.

Trigger errors

All in the standard envelope. The validation ones are 400 and arrive before anything is created.

StatusMessage
400invalid JSON body: ...
400name must be 1-52 characters of lowercase letters, digits and dashes, start with a letter and end with a letter or digit (also raised for a bad target.service)
400source.type must be "schedule", "pubsub" or "objectstore"
400source.schedule.cron is required when type is schedule
400any cron rejection — see cron rules
400source.pubsub.topic is required when type is pubsub
400source.objectstore.bucket is required when type is objectstore
400source.objectstore.endpoint is required - the platform does not host the object store
400source.objectstore.credentials_secret_name is required, and the secret must belong to this project
400source.objectstore.after_read must be "move", "delete" or "none" - there is no default, because a bucket trigger that never retires an object fires on it forever
400after_read: move needs move_to.bucket or move_to.prefix - moving an object onto itself re-delivers it on every poll
400rejected by the platform: ... (for example a retry.max_attempts outside 1–20)
404not found — the target does not exist in this project, or the trigger doesn't (the two are deliberately indistinguishable)
409a trigger with that name already exists in this project