Event-driven functions
So far you've called functions yourself: you send a request, the function answers. In this tutorial the platform calls your function for you. Every message published to a topic gets delivered to your function automatically. Failed deliveries are retried, and a message that keeps failing is parked in a dead-letter topic instead of being lost.
You'll build a tiny order-processing pipeline: a topic called orders, a function called order-worker, and the wiring between them. Then you'll deliberately break the function to watch the retry and dead-letter machinery work. Budget about 20 minutes.
What you're building
Three words to know before you start:
- A topic is a named channel you publish messages to.
- A subscription is a durable reader of that topic. It remembers its own place, so two subscriptions never steal each other's messages.
- A push subscription is one where the platform POSTs each message to an HTTP endpoint inside your project. A deployed function is exactly such an endpoint.
Functions answer plain HTTP calls and CloudEvent deliveries. A CloudEvent is the standard envelope of headers and fields that the platform wraps an event in. Functions have no trigger settings of their own, so something outside the function has to call it.
There are two ways to arrange that, and underneath they are the same machine. A Pub/Sub trigger is the short version: name a topic and a target, and the platform creates and manages the push subscription for you. This page builds that push subscription yourself, which is the long version — more typing, every knob in your hands. A trigger's subscription is always shared, always starts from new, always uses a 30-second acknowledgement deadline, and cannot be given a dead-letter topic at all. Dead-lettering is the second half of this tutorial, so the long version is the one that fits.
Before you begin
-
A platform account and a project — see Create an account.
-
The admin role on the project. Creating topics and subscriptions is admin-only; publishing, pulling, and deploying a function need only
member. -
Your project connected to Crusoe Cloud. Everything you deploy is built into a container image, and that image is stored in a repository in your own Crusoe Cloud Registry — so a project with no Crusoe Cloud credential is refused before anything is built. Connecting is a one-time, project-admin step, and a project that is already connected needs nothing new. See connect your Crusoe Cloud account.
-
platformctltab: the CLI built, signed in, and pointed at your project — see Install the CLI. -
curltab: the API address, a token, and your project id in your shell:export CAI_API=https://api.codyhill.devexport CAI_TOKEN="<your session token or API key>"export CAI_PROJECT="<your project id>"Pub/Sub is served on that same public API, so one address covers both halves of this tutorial. Your project id is a UUID — copy it out of the console URL, or run
platformctl projects listand read theIDcolumn. If your install serves Pub/Sub somewhere else,CAI_PUBSUB_APIoverridesCAI_APIfor the Pub/Sub calls only. -
Console tab: nothing else. A browser is enough — with one exception, called out in step 1.
-
jq, for pulling one field out of a JSON response.
Every step below is shown three ways — platformctl, curl, and the console. Pick whichever tab suits you and stay in it; the tabs are the same work through three different doors. Your choice follows you across every page in these docs.
CAI_TOKEN does double duty. curl sends it as a bearer token, meaning one HTTP header whose value is the credential. And platformctl prefers it over every other credential.
1. Write and deploy the function
The handler reads an order out of the event, prints a line, and returns. Printing is how you'll prove it ran. The return value of a function that receives an event is thrown away — more on that in step 7.
Pick a language. Every step after this one is identical whichever you chose, because the event-delivery contract is the same in all four.
- Python
- Node.js
- Go
- Ruby
mkdir order-worker && cat > order-worker/handler.py <<'EOF'
def handle(event):
order_id = event.get("order_id", "unknown")
total = event.get("total", 0)
print(f"processing order {order_id} for {total}")
return {"ok": True}
EOF
Use a plain def, never async def — see runtimes for why an async Python handler fails silently, and why it fails even more silently on an event delivery.
mkdir order-worker && cat > order-worker/handler.js <<'EOF'
'use strict';
function handle(event) {
const orderId = (event && event.order_id) || 'unknown';
const total = (event && event.total) || 0;
console.log(`processing order ${orderId} for ${total}`);
return { ok: true };
}
module.exports = { handle };
EOF
Node.js is the one runtime where async function handle(event) also works — the shim awaits your return value.
mkdir order-worker && cat > order-worker/handler.go <<'EOF'
package main
import "fmt"
func Handle(event map[string]any) (map[string]any, error) {
orderID, _ := event["order_id"].(string)
if orderID == "" {
orderID = "unknown"
}
total, _ := event["total"].(float64)
fmt.Printf("processing order %s for %v\n", orderID, total)
return map[string]any{"ok": true}, nil
}
EOF
Every JSON number arrives as a float64, which is why total is read as one. The signature must be exactly func Handle(event map[string]any) (map[string]any, error) — your file is compiled into the platform's server at build time, so a wrong signature fails the build rather than the request.
mkdir order-worker && cat > order-worker/handler.rb <<'EOF'
def handle(event)
order_id = (event['order_id'] if event.is_a?(Hash)) || 'unknown'
total = (event['total'] if event.is_a?(Hash)) || 0
puts "processing order #{order_id} for #{total}"
{ 'ok' => true }
end
EOF
Now deploy it.
- platformctl
- curl
- Console
platformctl functions deploy ./order-worker --name order-worker
You should see:
packaging ./order-worker...
uploading order-worker (0.3 KiB, framework=function, runtime=python)...
build 7c41d9e2-... accepted
state: -> building
state: building -> deploying
state: deploying -> ready
order-worker is ready at https://order-worker-ab12cd.apps.codyhill.dev
The runtime= on the upload line is whichever language you wrote — the CLI works it out from the handler file in your folder: handler.js means Node.js, handler.go means Go, handler.rb means Ruby, anything else means Python. Force it with --runtime python|nodejs|go|ruby.
Functions ride the same endpoint as agents. Send framework=function plus the language in a separate runtime field:
tar -czf order-worker.tar.gz -C order-worker .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=order-worker" \
-F "framework=function" \
-F "runtime=python" \
-F "code=@order-worker.tar.gz"
Swap runtime=python for nodejs, go, or ruby to match what you wrote. framework is always the bare word function — the language never goes in that field.
You should see HTTP 202 — the build runs in the background:
{"agent": "order-worker", "build_id": "7c41d9e2-8a1e-4c3b-9d2a-1b2c3d4e5f6a"}
Poll state until it settles on ready or failed:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/order-worker" | jq -r '.state, .runtime, .message'
Sign in at https://console.codyhill.dev, press Cmd+K / Ctrl+K to pick your project, go to Compute → Functions and click Deploy function. Name it order-worker, write or upload your source, and deploy. The build panel streams building, deploying, ready.
The dialog has a Language selector — Python, Node.js, Go, Ruby — and it seeds the editor with that runtime's handler file and dependency manifest: handler.py with requirements.txt, handler.js with package.json, handler.rb with Gemfile. Go gets handler.go and no manifest, because a Go handler is compiled into the shim's own module and can use only the standard library.
Choose it first. Each shim loads exactly one filename, so switching language after you have typed something leaves your file where it is and warns you that handler.py is not the file the Go shim loads — and deploying anyway fails the pre-flight check with the same complaint. An existing function keeps the language it was created with, so the selector is hidden when you redeploy.
The address a push subscription needs
The address in the deploy output is the function's public one. A push subscription needs its internal one instead: push delivery happens inside the platform, and the platform refuses to POST your messages at anything on the public internet.
- platformctl
- curl
- Console
platformctl status order-worker -o json | jq -r .url
You should see:
http://<private-hostname>
url is the internal address; public_url is the other one:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/order-worker" | jq -r .url
You should see:
http://<private-hostname>
Open the function and read Endpoint on its Overview panel. While the function is unpublished, that field carries the internal address, with a copy button next to it.
That address is reachable only from inside the platform, which is exactly what a push subscription needs. Keep it to hand:
export FN_URL=http://<private-hostname> # your internal URL from above
2. Create the two topics
orders carries the work. orders-dead is the dead-letter topic: it catches messages that fail over and over, so they are parked instead of lost.
Two rules about dead-letter topics. It must already exist before a subscription can name it. And it must be a different topic from the one it protects. Otherwise a poison message — one that fails every single time it is delivered — would be republished straight back into the queue it just poisoned.
- platformctl
- curl
- Console
platformctl pubsub topics create orders --max-bytes 16Mi --discard old
platformctl pubsub topics create orders-dead --max-bytes 16Mi --discard old
You should see, for each (trimmed — the table prints every field, sorted):
address persistent://p-ab12cd/main/orders
discard old
max_bytes 16Mi
name orders
path projects/ab12cd/topics/orders
ready false
state pending
The full table also carries the topic's counters, which live together under stats and print as stats.published, stats.delivered, stats.backlog_bytes, and their siblings. They are all zero on a topic this new.
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/topics" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"orders","max_bytes":"16Mi","discard":"old"}'
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/topics" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"orders-dead","max_bytes":"16Mi","discard":"old"}'
You should see, for each (HTTP 201, trimmed):
{"name":"orders","path":"projects/ab12cd/topics/orders","max_bytes":"16Mi","discard":"old",
"address":"persistent://p-ab12cd/main/orders",
"state":"pending","ready":false,"message":"not reconciled yet",
"stats":{"published":0,"delivered":0,"backlog_bytes":0,...}}
Go to Messaging → Pub/Sub and click Create topic. Name it orders, leave Max bytes at 16Mi, and leave When full on old. Then do it again for orders-dead.
You should see: each topic listed with its claimed budget and a state that reads pending and then ready. The form checks your remaining budget before it sends, so an over-budget size is caught in the dialog rather than as a 409.
pending is normal. The topic object exists immediately, and the message broker catches up a few seconds later. state is the topic's own word for where it is in that catch-up; ready is the boolean to wait on, and it means the same thing here as it does on an agent, an index, or a function.
Each project has a 1 GiB Pub/Sub storage budget. A topic claims its whole max_bytes as soon as it is created, even while empty. So two 16 Mi topics have already spent 32 Mi. Does a create fail with a message about the storage budget? Delete a topic, or lower another topic's max_bytes. Consuming messages never frees budget. platformctl pubsub quota shows what is left.
3. Create a pull subscription on the dead-letter topic
Do this before publishing anything. A message is kept only while some subscription still owes an acknowledgement for it: a signal from the reader saying it has finished with that message. Publish to a topic nobody is subscribed to, and the message is accepted, given an id, and then quietly reclaimed.
- platformctl
- curl
- Console
platformctl pubsub subscriptions create dead-watch --topic orders-dead \
--type shared --ack-deadline-seconds 30
You should see (trimmed):
ack_deadline_seconds 30
deliver.mode pull
max_deliver 5
name dead-watch
ready false
start_from all
state pending
topic orders-dead
type shared
A subscription's counters live under stats too — stats.backlog, stats.unacknowledged, stats.delivered, stats.consumers — and stats.consumers is the first thing to read when a push target is getting nothing.
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/topics/orders-dead/subscriptions" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"dead-watch","type":"shared","ack_deadline_seconds":30}'
You should see (HTTP 201, trimmed):
{"name":"dead-watch","topic":"orders-dead","type":"shared",
"ack_deadline_seconds":30,"max_deliver":5,"start_from":"all",
"deliver":{"mode":"pull"},
"state":"pending","ready":false,"message":"not reconciled yet",
"stats":{"backlog":0,"unacknowledged":0,"delivered":0,"consumers":0,...}}
On the Messaging → Pub/Sub page, click Create subscription. Set Topic to orders-dead, name it dead-watch, leave Delivery on pull and Type on shared, and leave the ack deadline at 30 seconds.
4. Create the push subscription
This is the wiring. It says: deliver every orders message to the function, in CloudEvents binary form, and give up after three failed attempts by moving the message to orders-dead.
- platformctl
- curl
- Console
platformctl pubsub subscriptions create to-worker --topic orders \
--type shared \
--ack-deadline-seconds 60 \
--max-deliver 3 \
--push-url "$FN_URL" \
--push-content-mode cloudevents-binary \
--dead-letter-topic orders-dead \
--dead-letter-after-attempts 3
--push-url selects push delivery on its own, so there is no --delivery push to remember.
You should see (trimmed):
ack_deadline_seconds 60
dead_letter.after_attempts 3
dead_letter.topic orders-dead
deliver.mode push
deliver.push.content_mode cloudevents-binary
deliver.push.url http://<private-hostname>
max_deliver 3
name to-worker
topic orders
type shared
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/topics/orders/subscriptions" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"to-worker\",\"type\":\"shared\",
\"ack_deadline_seconds\":60,
\"max_deliver\":3,
\"deliver\":{\"mode\":\"push\",
\"push\":{\"url\":\"$FN_URL\",\"content_mode\":\"cloudevents-binary\"}},
\"dead_letter\":{\"topic\":\"orders-dead\",\"after_attempts\":3}}"
You should see (HTTP 201, trimmed):
{"name":"to-worker","topic":"orders","type":"shared",
"ack_deadline_seconds":60,"max_deliver":3,
"deliver":{"mode":"push","push":{"url":"http://<private-hostname>",
"content_mode":"cloudevents-binary"}},
"dead_letter":{"topic":"orders-dead","after_attempts":3},...}
Click Create subscription again. Set Topic to orders, name it to-worker, and change Delivery to push — a Push URL field appears. Paste the internal address from step 1 into it. Then set CloudEvents binding to binary, Ack deadline to 60, Max deliver to 3, Dead-letter topic to orders-dead, and Dead-letter after to 3.
You should see: the new subscription in the table with a push badge, its target address in the Target column, and orders-dead ×3 under Dead letter.
Three choices there are worth understanding:
| Field | Why this value |
|---|---|
ack_deadline_seconds: 60 | On a push subscription the ack deadline doubles as the request timeout. Your function scales to zero when idle, so the first delivery has to pay for a cold start. 60 seconds is generous. The allowed range is 1–600. |
content_mode: cloudevents-binary | Binary mode puts your message payload in the request body and the event metadata in ce- headers. So your handler's event is your JSON payload, already parsed, with nothing to unwrap. Structured mode would instead wrap payload and metadata together in one envelope. |
max_deliver and after_attempts both 3 | Fail three times, then move the message to orders-dead instead of retrying forever. |
The push URL is strictly checked. It must be http://, no redirects are followed, and the target must be a workload in your own project. Do not assemble the address by hand — copy the url field from the target workload (platformctl status <workload> prints it, and the console's Overview panel shows it as Endpoint) and add your path. That string is exactly what this check accepts.
A public URL, or a service belonging to another project, is refused with a 400 that names the reason and shows the correct shape. This is deliberate: it means the platform can never be tricked into attacking a system for you.
5. Publish a message
The payload is JSON, so label it as JSON. Leave the label off and it can arrive tagged as raw bytes.
- platformctl
- curl
- Console
platformctl pubsub topics publish orders \
--message '{"order_id":"A-1001","total":42}' \
--attribute datacontenttype=application/json
You should see a message id:
1234:0
Publishing is a custom method: the literal :publish suffix on the topic path, with POST. A bare POST to the topic is a 405 that tells you the right URL.
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/topics/orders:publish" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"messages":[{"text":"{\"order_id\":\"A-1001\",\"total\":42}",
"attributes":{"datacontenttype":"application/json"}}]}'
You should see:
{"message_ids":["1234:0"]}
On the orders row, click Publish. Put {"order_id":"A-1001","total":42} in Message, and datacontenttype=application/json in Attributes, one key=value per line.
You should see: a confirmation naming the message id.
That id is written as ledger:entry — two numbers naming where the broker stored the message — and it is your proof the message was stored. Counters on dashboards lag by up to about five minutes. The publish response never does.
6. Confirm the function ran
Delivery happens within seconds, plus a cold start if the function was idle. Read the function's persisted logs: they survive scale-to-zero, and your function has probably gone back to sleep. A live tail needs a running instance.
- platformctl
- curl
- Console
platformctl logs order-worker --history
You should see a line like:
2026-08-12T18:04:11Z stdout processing order A-1001 for 42
Plain platformctl logs order-worker prints the newest running instance's buffered logs once, and -f/--follow tails new lines as they arrive. Both need an instance to be running — an idle function has scaled to zero, which is why --history is the one to reach for here.
Persisted history and the live tail are two different routes. GET /v1/agents/{name}/logs reads the running instance, and says so when there is none. GET /v1/agents/{name}/logs/history reads the saved lines:
curl -s -H "Authorization: Bearer $CAI_TOKEN" \
"$CAI_API/v1/agents/order-worker/logs/history" \
| jq -r '.lines[] | "\(.ts) \(.stream) \(.message)"'
You should see a line like:
2026-08-12T18:04:11Z stdout processing order A-1001 for 42
Open the function's page and scroll to Logs. It reads Persisted history by default, which is the right choice here; switch the selector to the live view only when you want to watch a request land in real time.
That's the whole loop. Publish a few more messages and watch them appear.
7. What an event delivery does differently
Your function is the same code whether you call it over HTTP or the platform delivers an event to it, and this is true in all four runtimes. Three rules change for event deliveries:
- The response is always an empty
204, and your return value is thrown away. That 204 is the acknowledgement the subscription is waiting for. So event handlers work by their side effects: writing to a store, calling another service, logging. - A handler failure answers
400, not500. For a plain HTTP call, a crash is a500. A400is not retried forever, which is what stops a deterministic bug becoming a retry storm. - Everything else is unchanged: the same handler function, the same 8 MiB body cap.
A push delivery counts as delivered only on a 2xx response, meaning any HTTP status from 200 to 299. Anything else is a failed attempt: an error status, or a response slower than the ack deadline. After a failure the platform waits and retries, waiting longer each time. That is backoff: 1 second, then 2, then 4, up to a ceiling of 60.
8. Watch a failure land in the dead-letter topic
Break the handler on purpose.
- Python
- Node.js
- Go
- Ruby
cat > order-worker/handler.py <<'EOF'
def handle(event):
raise RuntimeError("pretend the database is down")
EOF
cat > order-worker/handler.js <<'EOF'
'use strict';
function handle(event) {
throw new Error('pretend the database is down');
}
module.exports = { handle };
EOF
cat > order-worker/handler.go <<'EOF'
package main
import "errors"
func Handle(event map[string]any) (map[string]any, error) {
return nil, errors.New("pretend the database is down")
}
EOF
A non-nil error counts as a handler failure, exactly like a raised exception in the other three.
cat > order-worker/handler.rb <<'EOF'
def handle(event)
raise 'pretend the database is down'
end
EOF
Deploy the broken version and publish another order:
- platformctl
- curl
- Console
platformctl functions deploy ./order-worker --name order-worker
platformctl pubsub topics publish orders \
--message '{"order_id":"A-1002","total":99}' \
--attribute datacontenttype=application/json
tar -czf order-worker.tar.gz -C order-worker .
curl -s -X POST "$CAI_API/v1/agents" \
-H "Authorization: Bearer $CAI_TOKEN" \
-F "name=order-worker" \
-F "framework=function" \
-F "runtime=python" \
-F "code=@order-worker.tar.gz"
# wait for the redeploy to reach ready, then publish
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/topics/orders:publish" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"messages":[{"text":"{\"order_id\":\"A-1002\",\"total\":99}",
"attributes":{"datacontenttype":"application/json"}}]}'
Redeploying is the same POST /v1/agents as the first deploy: same name, new revision, same URL — so the push subscription keeps working without being touched.
Open the function and deploy it again with the broken handler — the deploy dialog is the same one you used in step 1. Then go back to Messaging → Pub/Sub, click Publish on the orders row, and send {"order_id":"A-1002","total":99} with the same datacontenttype=application/json attribute.
An upload replaces the whole source tree, so make sure the file you send is the only one you want.
Here is the sequence. The handler fails. The shim, which is the small wrapper the platform runs around your handler, answers 400. The subscription retries after 1 second, then after 2. After the third failed attempt the message is republished to orders-dead. Wait about fifteen seconds, then read it:
- platformctl
- curl
- Console
platformctl pubsub subscriptions pull dead-watch --topic orders-dead --max 10 --ack
You should see the message table on stdout and the acknowledgement on stderr:
ACK_ID ID KEY DATA
CIcJEAAYACAA 5678:0 - {"order_id":"A-1002","total":99}
acknowledged 1 message(s)
Pulling is another custom method. auto_ack pulls and acknowledges in one call:
curl -sX POST "$CAI_API/v1/projects/$CAI_PROJECT/topics/orders-dead/subscriptions/dead-watch:pull" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"max_messages":10,"auto_ack":true}'
You should see:
{"messages":[{"id":"5678:0","data":"eyJvcmRlcl9pZCI6...","attributes":{...},
"publish_time":"...","delivery_attempt":1}],
"acknowledged":1}
data on the way out is always base64, whichever way you published it. Pipe it through jq -r '.messages[].data' | base64 -d to read it.
On the dead-watch row, click Pull. Pulled messages are listed with an Acknowledge action, and an Acknowledge all button when several are outstanding.
The dead-lettered message also carries extra attributes whose names begin with cai-dead-letter-. They record where it came from, how many attempts it used, and why the last one failed. The curl tab's response shows them in full; on the CLI use -o json.
Does a push subscription have no dead-letter topic configured? Then a message that uses up its attempts is simply dropped. The only record is a line in the platform's own server log, which you cannot read. So configure a dead-letter topic for anything you cannot afford to lose — and put a subscription on that topic too, or the dead letters vanish as well.
Now put the working handler back: rewrite the file exactly as you wrote it in step 1, and deploy it again the same way.
9. Clean up
Deleting a topic deletes its subscriptions with it, so two deletes cover the messaging side. Order matters: orders goes first. A topic that some other topic's subscription uses as its dead-letter target refuses to be deleted while that subscription still exists, because deleting it would stop that subscription's failed messages going anywhere. The refusal is a 409 naming the culprit:
topic orders-dead is the dead-letter target for to-worker. Point those subscriptions
elsewhere first - deleting it would stop their failed messages going anywhere.
Deleting orders takes to-worker with it, which clears the way. The cascade is not instantaneous, so if the second delete still reports that 409, wait a few seconds and run it again.
- platformctl
- curl
- Console
platformctl pubsub topics delete orders
platformctl pubsub topics delete orders-dead
platformctl delete order-worker
You should see:
deleting topic orders and its subscriptions
deleting topic orders-dead and its subscriptions
deleted order-worker
curl -sX DELETE "$CAI_API/v1/projects/$CAI_PROJECT/topics/orders" \
-H "Authorization: Bearer $CAI_TOKEN"
curl -sX DELETE "$CAI_API/v1/projects/$CAI_PROJECT/topics/orders-dead" \
-H "Authorization: Bearer $CAI_TOKEN"
curl -s -X DELETE "$CAI_API/v1/agents/order-worker" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see (HTTP 202 for each topic):
{"name":"orders","state":"deleting","ready":false}
{"name":"orders-dead","state":"deleting","ready":false}
{"agent":"order-worker","deleted":true}
- On Messaging → Pub/Sub, click Delete on the
ordersrow and confirm, then the same fororders-dead. The confirmation names the subscriptions that go with each topic. - On the function's page, click Delete and confirm.
What to carry into production
| Rule | What it means for your code |
|---|---|
| Delivery is at-least-once | Every message arrives, but the same message can arrive twice. Make your handler idempotent — safe to run again with the same end result. For example, ignore an order_id you have already processed. |
| Subscriptions must exist before you publish | A message published to a topic with no subscription is accepted and then reclaimed. Create the reader first. |
| The ack deadline is also the timeout | Say your handler can take 90 seconds. An ack_deadline_seconds of 30 will fail it and retry it forever. Size the deadline to your slowest run, plus a cold start. |
| Always set a dead-letter topic | Otherwise poison messages disappear silently. And subscribe to the dead-letter topic, or its messages are reclaimed too. |
| Push targets are project-local | The platform will only POST to a service inside your own project. |
| Topics claim budget, not usage | A topic reserves its max_bytes from the project's 1 GiB the moment it exists. |
Next steps
- Publish and consume — pull, push, acknowledgements, and delivery semantics in full.
- Topics and subscriptions — every field on both objects and what it changes.
- Serverless triggers — the short version of this wiring, plus schedules and object-store events.
- HTTP and events — the complete request/response contract for functions.
- Runtimes — the full handler contract for Python, Node.js, Go, and Ruby.
- Functions troubleshooting — real error strings and what causes them.
- Pub/Sub API reference — endpoints, defaults, and error messages.
Go deeper
These advanced guides pick up where the quickstarts stop, each exercising a different slice of the platform:
| Guide | Framework / language |
|---|---|
| Multi-step research agent | LangGraph |
| Editorial pipeline with a crew | CrewAI |
| Support agent over your own docs | ADK |
| Document ingestion pipeline | Python |
| Webhook fan-out, exactly once | Node.js |
| Scheduled reconciliation job | Go |
| Object-store ETL with move-after-read | Ruby |