Skip to main content

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.

SourceBody the agent receivesFits /invoke
scheduleYour payload, character for characterYes — make the payload an invoke request
objectstoreThe object's own bytesOnly when the object is an invoke request
pubsubOne JSON event envelope, your payload base64-encoded inside itNo — 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 payload out and the body is {} — a valid JSON document and not a valid invoke request. The agent answers 422, 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_id each firing starts a new session; with no user_id every firing lands under the agent's built-in default-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 url the platform already gives you for it — the field platformctl status <agent> prints — with /invoke on 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 datacontenttype attribute, and the default is application/octet-stream — which the agent refuses with 422 even 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 new is what a trigger would have done for you.

The rest of a subscription's settings — acknowledgement, redelivery, dead-lettering — are in publish and consume.

Do not try to fix a trigger's subscription by hand

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. 2xx means 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 answersschedulepubsubobjectstore
Any 2xxDoneAcknowledgedDone
429 or any 5xxRetriedRetriedNot retried
Any other 4xx — 404, 422Terminal. Never retried.Retried to the limit, then droppedNot retried
No answer — timeout, refused connectionRetriedRetriedNot 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.

A delivery attempt gets 30 seconds; an agent turn is allowed 60

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.