Trigger an agent
A trigger calls your agent for you, so nobody has to make the HTTP request by hand: every night at two, on each message published to a topic, or for each object that appears in a bucket.
None of this is framework code. The platform makes the request, and an ADK, LangGraph or CrewAI agent receives exactly the same one, because all three serve the same /invoke. There is nothing to import and nothing to add to your agent — a trigger is configuration beside it.
This page is the agent-side guide: which sources can drive an agent, and what each one costs when a delivery fails. The field-by-field reference — every source's settings, cron syntax, the credentials Secret — is triggers, and the scheduled walkthrough for an agent is invoke on a schedule.
Two rules, or nothing arrives
1. The path must be /invoke. A trigger appends target.path to your agent's address, and that field defaults to /, where an agent serves nothing. A trigger left on the default delivers, gets 404, and on a scheduled trigger that answer is never retried.
2. The body must be an invoke request. A trigger posts straight to your agent, not through POST /v1/agents/{name}/invoke. Everything that route does for you is therefore absent: no minted session_id, no derived user_id, and no 400 message is required. The agent's own harness validates the body, so a body it cannot read comes back as 422 from the agent. The smallest thing that works is:
{"message": "summarise the last 24 hours of support tickets"}
Delivery uses your agent's private address on the platform's own network, so a private agent is triggerable and CAI_EXPOSE_EXTERNAL has nothing to do with it. Publishing an agent neither helps nor hinders a trigger.
Which source can carry an invoke request
The three sources differ in the one respect that decides everything: what ends up in the request body.
| Source | Body the agent receives | Fits /invoke |
|---|---|---|
schedule | Your payload, character for character | Yes — make the payload an invoke request |
objectstore | The object's own bytes | Only when the object is an invoke request |
pubsub | One JSON event envelope, your payload base64-encoded inside it | No — use a push subscription |
Where the body is your own payload — a schedule, an object, or a Pub/Sub push subscription in binary mode — the event metadata rides in ce-* headers instead of wrapping it. Your agent ignores those headers, and that is exactly why the body can be an invoke request at all. The one source that wraps is a pubsub trigger, which is why it does not fit.
Schedule
The scheduled walkthrough has the three doors and the cron rules. Two agent-specific details are not there:
- Leave
payloadout and the body is{}— a valid JSON document and not a valid invoke request. The agent answers422, a scheduled trigger never retries that, and the trigger records a failed run whose reason lives only in that firing's log line. A payload is effectively mandatory when the target is an agent. - An omitted id is the agent's decision, not the API's. With no
session_ideach firing starts a new session; with nouser_idevery firing lands under the agent's built-indefault-user. Pin both in the payload —{"message": "...", "session_id": "nightly-rollup", "user_id": "nightly-rollup"}— when you want one growing conversation and one memory owner. Prefer a fresh session otherwise: history is replayed into the model on every turn, so a session that grows forever costs more every night.
Pub/Sub: deliver the message, not an envelope
A pubsub trigger wraps each message in a JSON event envelope with your payload base64-encoded inside it:
{"specversion":"1.0",
"type":"ai.crusoe.pubsub.message.v1",
"source":"//pubsub.crusoe.ai/topics/orders",
"id":"8f1c...",
"subject":"orders",
"datacontenttype":"application/json",
"subscription":"order-ingest",
"deliveryattempt":1,
"data_base64":"eyJtZXNzYWdlIjoic3VtbWFyaXNlIG9yZGVyIG8tMTIzIn0="}
That is the right shape for a function, whose handler is given the unwrapped payload. An agent is not a function: /invoke reads the body as its own request, finds no message field, and answers 422. Every attempt fails identically.
Ask the trigger for binary mode. --content-mode cloudevents-binary delivers the published payload as the body unchanged and moves the envelope into ce-* headers — the one shape /invoke accepts:
platformctl serverless triggers create summarise-orders \
--target <the agent> \
--target-path /invoke \
--type pubsub \
--topic orders \
--content-mode cloudevents-binary
Send --content-mode cloudevents-binary for any trigger pointed at an agent. It belongs to a Pub/Sub source and is refused anywhere else — see trigger content modes.
Or use a Pub/Sub push subscription, which has the same binary mode and is the route to take when you want the subscription's own controls (--start-from, a dead-letter topic) rather than a trigger's.
platformctl pubsub subscriptions create summarise-orders \
--topic orders \
--push-url "<the agent's url>/invoke" \
--push-content-mode cloudevents-binary \
--start-from new
Four things decide whether this works:
-
The address. A push target must be a workload in your own project, addressed by the
urlthe platform already gives you for it — the fieldplatformctl status <agent>prints — with/invokeon the end. Do not assemble a hostname by hand. -
The payload is the invoke request. Publish
{"message": "..."}, not a wrapper around it. Whatever you publish is what the agent receives. -
Label it JSON. In binary mode the request's content type comes from the message's own
datacontenttypeattribute, and the default isapplication/octet-stream— which the agent refuses with422even when the bytes are perfect JSON:platformctl pubsub topics publish orders \--message '{"message": "summarise order o-123"}' \--attribute datacontenttype=application/json -
--start-from. A new subscription begins at every retained message unless you say otherwise, so creating one against a topic that already holds a backlog invokes your agent once per retained message, immediately.--start-from newis what a trigger would have done for you.
The rest of a subscription's settings — acknowledgement, redelivery, dead-lettering — are in publish and consume.
A pubsub trigger creates and owns a subscription, and it rewrites that subscription from its own settings every couple of minutes. Switching that subscription to binary mode with platformctl pubsub subscriptions update appears to work and is silently reverted within minutes, which reads as an intermittent failure rather than a reverted edit. Create your own subscription instead of editing a trigger's.
Object store: only when the object is the request
An objectstore trigger streams the object's own bytes as the request body. There is no bucket field, no key field, and no wrapper, so it reaches an agent only when each object is an invoke request:
{"message": "extract the totals from invoice 4471"}
Prove that with one object before you rely on it — /invoke parses a body only when the request says the body is JSON — because a refused delivery here is the most expensive of the three. An object-store delivery is never retried, and after_read: move or delete has already retired the object by the time the agent refuses it. The work is gone, and the bucket looks like it was done. A retry block is accepted on this kind of trigger and does nothing.
If the objects are anything else — a CSV, a PDF, an image — put a function on the bucket and let it call the agent with a message it composed.
What the agent has to answer
Any 2xx, and nothing else matters. The agent answers normally — 200 with output, session_id, tool_calls and the rest of the invoke response — and the trigger reads the status code and discards the body. Two consequences worth planning around:
- The answer goes nowhere unless the agent sends it somewhere itself. If a person is meant to see it, have the turn write to a tool, a topic or a bucket; otherwise read it afterwards in the session transcript or the logs.
- A turn your guardrails blocked still answers
200, because a refusal is a normal reply whose text is the refusal. To a trigger that is a success.2xxmeans delivered, never the work happened.
When the agent refuses a delivery
The status code decides what happens next, and the three sources do not agree — which matters most for 422, the answer an agent gives to a body it cannot read.
| The agent answers | schedule | pubsub | objectstore |
|---|---|---|---|
Any 2xx | Done | Acknowledged | Done |
429 or any 5xx | Retried | Retried | Not retried |
Any other 4xx — 404, 422 | Terminal. Never retried. | Retried to the limit, then dropped | Not retried |
| No answer — timeout, refused connection | Retried | Retried | Not retried |
Where retries exist they are retry.max_attempts total attempts (default 5: one try plus four more), with exponential backoff capped at 60 seconds. A scheduled trigger that exhausts them logs the last failure verbatim, which is what makes a broken trigger findable rather than merely broken:
giving up after 5 attempts: http 502: {"error": "The model provider did not complete this request...", "code": "model_provider_error"}
A Pub/Sub message that exhausts them is dropped, because a trigger-owned subscription has no dead-letter topic. A subscription you created yourself can have one.
To see any of this, read one trigger rather than the list: runs reports the last few firings and how each ended. An object-store trigger has none — its poller runs continuously and leaves no per-firing record, so that source's failures are visible only in the target agent's logs.
The split is worth reading twice. A model provider hiccup answers 429 or 502, and every retrying source gets another go — that is the case retries exist for, along with the cold start on the first attempt after an agent has scaled to zero. A malformed payload answers 422, and a scheduled trigger gives up at once, on the reasoning that a body that was wrong at 02:00 will still be wrong at 02:01.
On a schedule and on a Pub/Sub delivery, one attempt gets 30 seconds — half the invoke timeout. A turn that needs 45 seconds — a long tool chain, or a cold start in front of a large model — is a failed attempt as far as the trigger is concerned, and the next attempt delivers the same request again while the first turn may still be finishing. The result is duplicated work that no error anywhere reports.
So keep trigger-driven turns short, and make them safe to repeat. ce-id is stable across the retries of one firing (ce-deliveryattempt counts them), so a turn that records what it has already done can recognise a redelivery — or have the trigger ask for something quick that hands the long job off.