diff --git a/docs/content/guides/ai-workflows.mdx b/docs/content/guides/ai-workflows.mdx
new file mode 100644
index 0000000..092af15
--- /dev/null
+++ b/docs/content/guides/ai-workflows.mdx
@@ -0,0 +1,191 @@
+---
+title: "Multi-Step AI Workflows"
+description: "Chain LLM calls and tool calls into one job that picks up at the failed step after a crash or a rate-limit error."
+---
+
+import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";
+
+
+
+
+
+What we'll build:
+
+- An `aiJob` workflow per job that turns a meeting transcript into a short brief for your sales team.
+- Two model steps, extract and summarize, that retry with backoff when the provider is rate limited or down.
+- Enrich steps that look up each company in your CRM between the two model calls.
+- A publish step that posts the brief to your CMS exactly once.
+- A `/jobs` route that starts a job and returns its id right away, and progress events that tell your app which step a job is on.
+
+
+
+
+
+```sh
+npm add @rivet-dev/workflows rivetkit hono @hono/node-server
+```
+
+
+
+
+
+Set your model key and the base URLs of your CRM and CMS APIs:
+
+```sh
+OPENAI_API_KEY=sk-...
+CRM_API_URL=https://crm.internal.example.com
+CMS_API_URL=https://cms.internal.example.com
+```
+
+`OPENAI_BASE_URL` and `OPENAI_MODEL` are optional. Point `OPENAI_BASE_URL` at any server that speaks the OpenAI chat completions format.
+
+
+
+
+
+Every model call goes through one function:
+
+
+
+- Keeping the call in one file means the workflow never depends on a provider. To use Anthropic, Gemini, or an SDK, change only this function.
+- A `429` or `5xx` throws a plain `Error`. The workflow step that called it retries with backoff.
+- Any other error throws `CriticalError`, which stops retries right away. A bad API key or a malformed request fails the same way every time, so retrying only wastes time.
+
+
+
+
+
+Each job is its own `aiJob` workflow, keyed by the job id. It waits for a `start` message, then runs the extract step:
+
+
+
+- `ctx.step` records the step's return value. After a crash or a deploy, the workflow replays and gets the recorded value back instead of calling the model again, so you never pay twice for the same answer.
+- `MODEL_STEP` gives model calls up to 7 attempts. The first retry waits 4 seconds and the wait doubles up to 60 seconds, which is enough for most rate limits to clear. The workflow sleeps durably between attempts, so a long wait does not hold a process.
+- Model calls can be slow, so the step timeout is 2 minutes instead of the default 30 seconds. `retryOnTimeout` makes a timeout retry like any other error.
+- If the model replies with invalid JSON, `JSON.parse` throws and the step asks again.
+
+
+
+
+
+Between the two model calls, the workflow looks up each company in your CRM:
+
+
+
+
+
+
+- Each lookup is its own step, named by its position. If the third lookup fails, only that one retries, and the first two are not repeated.
+- The loop is safe to replay because the list of companies comes from the recorded extract step, so every replay sees the same list in the same order.
+
+
+
+
+
+The summarize step sends the transcript, the action items, and the CRM data back to the model:
+
+
+
+- It uses the same `MODEL_STEP` retry settings as the extract step.
+- `companies` and `extracted` are recorded results from earlier steps, so this step can run in a later process than the ones that produced them.
+
+
+
+
+
+The last step posts the brief to your CMS:
+
+
+
+- A step can run more than once. If the process dies after your CMS saved the post but before the step was recorded, the step runs again.
+- `publishPost` sends the job id as an `idempotency-key` header, so your CMS can return the existing post instead of creating a second one. See `tools.ts` in the previous step.
+
+
+
+
+
+Every step calls `setStage`, and the `onError` hook reports retries:
+
+
+
+
+
+
+- The stage lives in `state`, so a client that connects late can call `getStatus` and see where the job is.
+- `broadcast` pushes each change to every client connected to the job. No separate pub/sub service is needed.
+- `onError` sees each failed attempt with its retry delay. It only observes, so it is a good place for progress updates and error reporting, but not for work the job depends on.
+
+
+
+
+
+The HTTP server has one route. It creates a workflow for the job and hands it the input:
+
+
+
+- `send` puts the input on the job's `start` queue and returns as soon as it is stored, so the route answers in milliseconds even though the job takes much longer.
+- A new random id per request means each POST starts its own job. To make retries of the same request safe, derive the id from something stable, such as a document id, and `getOrCreate` returns the existing job.
+
+
+
+
+
+Start the server:
+
+```sh
+npx tsx server.ts
+```
+
+Then submit a job and follow its progress:
+
+
+
+```sh
+npx tsx client.ts
+```
+
+The client prints each stage as it happens, including any retries, and then the job's final state.
+
+
+Stop the server while a job is running and start it again. The job continues from the step it was on, and steps that already finished do not call the model or your APIs again.
+
+
+
+
+
+
+## Next steps
+
+- [Human-in-the-Loop Approvals](/guides/human-in-the-loop): have a person approve the brief before the publish step runs.
+- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#join): look up every company at once with `join` instead of one after another.
+- [Failure & Recovery](/workflows/docs/failure-and-recovery#rollback): undo earlier steps when a later one fails.
+
+## Related docs
+
+- [Steps](/workflows/docs/steps): how steps are recorded and replayed.
+- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#timeouts): step timeouts and retry settings.
+- [Failure & Recovery](/workflows/docs/failure-and-recovery#error-hooks): the `onError` hook.
+- [Patterns](/workflows/docs/patterns): storing progress in state and broadcasting it.
diff --git a/docs/content/guides/handle-webhooks.mdx b/docs/content/guides/handle-webhooks.mdx
new file mode 100644
index 0000000..117a34f
--- /dev/null
+++ b/docs/content/guides/handle-webhooks.mdx
@@ -0,0 +1,180 @@
+---
+title: "Handle Incoming Webhooks"
+description: "Accept webhooks from Stripe, GitHub, or Shopify, process each event once, and keep events for the same customer in order."
+---
+
+import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";
+
+
+
+
+
+Webhook providers send each event at least once. They resend it when your server is slow to answer or answers with an error, and they do not promise to send events in order. This guide puts a workflow in front of your database so each event is applied once, and events about the same customer are applied one after another.
+
+What we'll build:
+
+- A `/webhooks` route that checks the provider's signature and rejects anything that fails.
+- A `customerEvents` workflow per customer, keyed by the customer id in the event.
+- A record of event ids in the workflow, so a redelivered event is skipped instead of applied twice.
+- An answer sent as soon as the event is queued, well inside the provider's timeout.
+- Steps that apply each event to your database in arrival order and retry with backoff when your database is down.
+
+The example uses Stripe. The same shape works for GitHub, Shopify, or any provider that signs its requests.
+
+
+
+
+
+```sh
+npm add @rivet-dev/workflows rivetkit hono @hono/node-server
+```
+
+
+
+
+
+Stripe signs every webhook with a secret that belongs to the endpoint. In the Stripe Dashboard, add an endpoint for `https:///webhooks` and copy its signing secret. To test locally, the Stripe CLI prints one when you run `stripe listen`. Then set:
+
+```sh
+STRIPE_WEBHOOK_SECRET=whsec_...
+```
+
+
+
+
+
+Anyone can post to a public URL, so the route checks that each request came from Stripe before it does anything else:
+
+
+
+- Stripe sends a `Stripe-Signature` header with a timestamp and an HMAC-SHA256 of `.`. The check recomputes it with your secret and compares the two with `timingSafeEqual`, which takes the same time whether or not they match.
+- Requests signed more than 5 minutes ago are rejected, so someone who captures a request cannot send it again later.
+- The official `stripe` package does the same check with `stripe.webhooks.constructEvent`. Use it if you already depend on it.
+
+
+Other providers sign differently. GitHub sends `X-Hub-Signature-256`, a hex HMAC-SHA256 of the raw body. Shopify sends `X-Shopify-Hmac-Sha256`, a base64 HMAC-SHA256 of the raw body. Swap in a check for your provider. The rest of this guide stays the same.
+
+
+
+
+
+
+Each customer gets a `customerEvents` workflow, keyed by their Stripe customer id. It takes events off its queue one at a time and records each event id before it handles the event:
+
+
+
+- Queue `send` has no idempotency key, so the workflow dedupes by event id. Stripe gives every event a unique `id` and keeps it the same on every retry.
+- The check and the write happen in one step, and the workflow handles one event at a time. A redelivery that arrives while the first copy is still queued is still caught.
+- The step's result is saved in the workflow's history. If the Rivet Actor restarts, it replays the saved result instead of checking again, so an event is never counted as new twice.
+- Ids are kept for 7 days, longer than Stripe's 3 days of retries, then dropped so `state` stays small.
+
+
+
+
+
+Once an event is known to be new, the workflow writes it to your database:
+
+
+
+
+
+
+- Events for one customer are applied one after another, in the order they reached your server. Events for different customers run in parallel, because each customer has its own workflow.
+- If your database is down, `tryStep` retries with backoff, starting at 1 second and doubling up to 1 minute. The waits are durable, so the Rivet Actor can sleep or restart in between.
+- If every retry fails, the event is added to `failed` instead of stopping the workflow. That list is your dead letter queue: later events keep flowing, and you can replay the failed ones once the cause is fixed.
+- `db.ts` keeps rows in memory so the example runs on its own. Replace `applyEvent` with writes to your database.
+
+
+Make the write idempotent, such as an upsert keyed by the object id. If the process dies after the write commits but before the step is saved, the step runs again on restart.
+
+
+
+Stripe does not promise to deliver events in the order they happened. When order matters, compare the event's `created` time with the last one you applied, or fetch the latest version of the object from Stripe before writing it.
+
+
+
+
+
+
+The HTTP server has one route. It verifies the request, hands the event to the right customer's workflow, and answers:
+
+
+
+- The route reads the raw body with `c.req.text()`. The signature covers the exact bytes Stripe sent, so check it before parsing the JSON.
+- Events are keyed by `customer`. Events about a customer object have no `customer` field, so the object's own id, which is the customer id, is used instead.
+- `send` resolves once the event is stored in the workflow's queue. The route answers `200` right away instead of waiting for your database, so it stays well inside the few seconds providers wait before they give up and retry.
+- A request with a bad signature gets a `400` and never reaches the workflow.
+
+
+
+
+
+Start the server:
+
+```sh
+STRIPE_WEBHOOK_SECRET=whsec_test npx tsx server.ts
+```
+
+In another terminal, post a signed test event, then the same event again, then one with a forged signature:
+
+
+
+```sh
+STRIPE_WEBHOOK_SECRET=whsec_test npx tsx send-test-event.ts
+```
+
+Both copies are accepted, but the event is applied once:
+
+```text
+delivery: 200 {"received":true}
+redelivery: 200 {"received":true}
+forged: 400 invalid signature
+{ applied: 1, duplicates: 1, failed: [] }
+```
+
+The server logs a single `applied evt_test_... (customer.subscription.updated): sub_123 is active` line.
+
+To try real Stripe events, forward them to the server with the Stripe CLI and start the server with the secret it prints:
+
+```sh
+stripe listen --forward-to localhost:3000/webhooks
+stripe trigger customer.subscription.updated
+```
+
+
+To receive webhooks from a provider before you deploy, run `ngrok http 3000` and use the ngrok URL as `https://`.
+
+
+
+
+
+
+## Next steps
+
+- [Reliable Webhook Delivery](/guides/webhook-delivery): send your own events to customer endpoints with retries.
+- [Human-in-the-Loop Approvals](/guides/human-in-the-loop): ask a person before acting on an event, such as a refund request.
+- [Failure & Recovery](/workflows/docs/failure-and-recovery#error-hooks): get notified when an event lands in `failed`.
+
+## Related docs
+
+- [Queues & Messaging](/workflows/docs/queues#queue-driven-worker): the queue-driven worker this guide builds on.
+- [Steps](/workflows/docs/steps#handling-terminal-failures-as-data): how `tryStep` returns failures as data.
+- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#timeouts): retries, backoff, and why steps should be idempotent.
+- [Reference](/workflows/docs/reference#recommendations): recommendations for replay-safe workflows.
diff --git a/docs/content/guides/human-in-the-loop.mdx b/docs/content/guides/human-in-the-loop.mdx
new file mode 100644
index 0000000..821a8c7
--- /dev/null
+++ b/docs/content/guides/human-in-the-loop.mdx
@@ -0,0 +1,194 @@
+---
+title: "Human-in-the-Loop Approvals"
+description: "Pause a workflow or an agent's tool call until a person approves or rejects it, even if the answer takes days."
+---
+
+import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";
+
+
+
+
+
+What we'll build:
+
+- An `approval` workflow per request that records which tool should run, with which arguments, and who must answer.
+- A Slack message that shows the request to the approver with Approve and Reject buttons.
+- A durable wait on the decision queue that can last for days without a running server.
+- A timeout that expires the request and tells the requester when no one answers.
+- The same gate wrapped around an agent's tool, so the tool runs only after a person approves it.
+
+
+
+
+
+```sh
+npm add @rivet-dev/workflows rivetkit hono @hono/node-server
+```
+
+
+
+
+
+Create a Slack app and give its bot the `chat:write` scope. Install it to your workspace. Then set:
+
+```sh
+SLACK_BOT_TOKEN=xoxb-...
+SLACK_SIGNING_SECRET=...
+APPROVER_SLACK_ID=U...
+AGENT_SLACK_CHANNEL=C...
+ORDERS_API_URL=https://api.example.com
+```
+
+`APPROVER_SLACK_ID` is the Slack user who answers requests. `AGENT_SLACK_CHANNEL` is where the requester hears the outcome. You turn on button clicks in the last step, once the server is running.
+
+
+
+
+
+The tools that need approval live in one place. The workflow runs them, never the caller:
+
+
+
+- A request names a tool and its arguments as data, such as `{ tool: "refund_order", args: { ... } }`. Code cannot be put on a queue, so the workflow looks the tool up by name when it runs it.
+- Each call sends the request id as an idempotency key. If the call is retried, your API can return the first result instead of refunding twice.
+
+
+
+
+
+Each request is its own `approval` workflow, keyed by a request id. It waits for the request, records it, and asks the approver:
+
+
+
+
+
+
+- The request is stored in `state`, so `getStatus` can show what is being asked at any time, even while the workflow waits.
+- Each button's `value` is the request id. That is all `/approvals` needs to find the right workflow.
+- Without `SLACK_BOT_TOKEN`, `postMessage` logs the message instead of posting it, so you can try the flow before Slack is set up.
+- To approve by email instead, send two links to a route of your own. The route then sends the decision the same way `/approvals` does below.
+
+
+
+
+
+The workflow then waits for a `decision` message or for the timeout, whichever comes first:
+
+
+
+- `nextBatch` with a `timeout` returns an empty list when the time runs out. With a message, it returns that message.
+- The deadline is recorded in the workflow's history. While it waits, the Rivet Actor can sleep and no process is held. A decision message or the deadline wakes it up, even after a restart or a deploy.
+- `names: ["decision"]` reads only decisions, so nothing else on the queue can be mistaken for an answer.
+
+
+
+
+
+On approve, the workflow runs the tool. Every path records the outcome and tells the requester:
+
+
+
+- No decision means the request expired. Expired and rejected requests skip the tool entirely.
+- `tryStep` retries the tool with backoff. If it keeps failing, it returns the error as data instead of failing the workflow, so the outcome is `failed` and the requester still hears about it.
+- `broadcast` sends the outcome to connected clients, which is how a waiting agent tool gets its answer. The `notify-requester` step also posts it in Slack.
+
+
+
+
+
+Slack posts every button click to `/approvals`. The route checks the request, then hands the decision to the workflow:
+
+
+
+- `verifySlackRequest` in `slack.ts` checks Slack's signature and rejects requests more than 5 minutes old.
+- `getStatus` reads the waiting workflow directly. A late click on an expired or already decided request gets a short reply instead of a second decision.
+- Only the assigned approver can answer. Anyone else who clicks gets an ephemeral reply that only they can see.
+- The route replaces the original message through Slack's `response_url`, so the buttons disappear once someone has answered.
+
+
+
+
+
+An agent tool can ask for approval the same way. Its `execute` waits until a person answers:
+
+
+
+- `requestApproval` starts a workflow with a new id, sends the request, and waits for the `finished` event.
+- The tool shape, with a name, a description, JSON Schema parameters, and `execute`, works with most agent frameworks. Adapt it to yours.
+- The refund itself happens in the workflow's `run-tool` step, not in the agent. Even a confused agent cannot refund without a person saying yes.
+
+
+The tool waits only as long as its process runs. The request, the decision, and the refund live in the workflow, so they continue even if the agent restarts. Keep the request id and call `getStatus` to pick up the outcome later.
+
+
+
+
+
+
+Start the server:
+
+```sh
+npx tsx server.ts
+```
+
+In your Slack app settings, open **Interactivity & Shortcuts**, turn it on, and set the **Request URL** to `https:///approvals`.
+
+
+If you haven't deployed yet, run `ngrok http 3000` and use the ngrok URL as `https://`.
+
+
+Then call the tool the way an agent would:
+
+
+
+```sh
+npx tsx client.ts
+```
+
+The approver gets a Slack message. When they click **Approve**, the refund runs and `client.ts` prints the result.
+
+To test without Slack, leave `SLACK_BOT_TOKEN` unset. The server logs the request id. Answer it from another terminal:
+
+
+
+```sh
+npx tsx decide.ts approve
+```
+
+
+
+
+
+## Next steps
+
+- [Multi-Step AI Workflows](/guides/ai-workflows): add an approval before an AI job publishes its result.
+- [Human in the Loop for agents](/agents/docs/human-in-the-loop): ask the user before an agent acts.
+- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#race): use `race` when the wait has more than two ways to end.
+
+## Related docs
+
+- [Queues & Messaging](/workflows/docs/queues#human-approval-gate): the basic approval gate.
+- [Steps](/workflows/docs/steps#handling-terminal-failures-as-data): how `tryStep` returns failures as data.
+- [Timers & Concurrency](/workflows/docs/timers-and-concurrency): durable sleeps and timeouts.
+- [Patterns](/workflows/docs/patterns): storing progress in state and broadcasting it.
diff --git a/docs/content/guides/webhook-delivery.mdx b/docs/content/guides/webhook-delivery.mdx
new file mode 100644
index 0000000..7820bfc
--- /dev/null
+++ b/docs/content/guides/webhook-delivery.mdx
@@ -0,0 +1,194 @@
+---
+title: "Reliable Webhook Delivery"
+description: "Send your app's events to customer endpoints in order, retry failed deliveries, and turn off endpoints that keep failing."
+---
+
+import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";
+
+
+
+
+
+When your customers subscribe to webhooks, their endpoints will be slow, down for a deploy, or broken for days. This guide gives each customer endpoint its own workflow that delivers events in order, retries with backoff, keeps a log of every attempt, and stops sending to an endpoint that keeps failing until the customer turns it back on.
+
+What we'll build:
+
+- An `endpoint` workflow per customer endpoint that holds an ordered outbox of events to deliver.
+- Signed payloads, so customers can check that each webhook came from you.
+- Retries with backoff and a record of every attempt, with its status code and timing.
+- A rule that disables an endpoint after repeated failures and keeps its events until it is turned back on.
+- Actions your customer portal calls to show the delivery log and re-enable an endpoint.
+
+
+
+
+
+```sh
+npm add @rivet-dev/workflows rivetkit hono @hono/node-server
+```
+
+
+
+
+
+Every request carries a signature, so a customer can tell your webhooks apart from anyone else posting to their URL:
+
+
+
+- The headers follow the Standard Webhooks spec. `webhook-signature` is an HMAC-SHA256 of `..`, keyed by the endpoint's secret, so customers can check it with any Standard Webhooks library.
+- `webhook-id` is the event id. It stays the same on every retry, so customers can use it to skip events they have already handled.
+- Each endpoint gets its own secret from `createSecret`. A leaked secret affects only that endpoint.
+
+
+
+
+
+Each customer endpoint is its own `endpoint` workflow, keyed by an endpoint id. Its queue is the outbox:
+
+
+
+- Events come off the queue one at a time, in the order your backend sent them. The next event stays in the queue until the current one is delivered, so a customer never sees event 2 before event 1.
+- The inner `deliver-event` loop keeps working on the same event until it is delivered, including across a disable and re-enable.
+- One slow or broken endpoint backs up only its own outbox. Every other endpoint has its own workflow and keeps receiving events.
+- The URL and secret are set in `onCreate` from the input your backend passes when it creates the endpoint.
+
+
+
+
+
+The delivery itself is one step that posts the signed event and records the attempt:
+
+
+
+- Any `2xx` answer counts as delivered. Any other status, a network error, or no answer within 10 seconds throws, and `tryStep` retries after 2, 4, 8, 16, and 32 seconds.
+- The waits between retries are durable. The Rivet Actor can sleep or restart during a long backoff and picks up at the next attempt.
+- The attempt is pushed to `attempts` before the step throws. A failed step keeps the changes it made to `state`, so the log shows every try, not just the last one.
+- The log keeps the newest 100 attempts. Store attempts in your own database if you need a longer history.
+
+
+These retries span about a minute so the example is quick to try. Real endpoints can be down for hours, so raise `MAX_RETRIES` and `RETRY_BACKOFF_MAX_MS` for production. For example, 20 retries capped at 1 hour keep trying for about 10 hours.
+
+
+
+Delivery is at least once. If a customer's server handles a request but its answer is lost or arrives after the timeout, the event is sent again. Tell customers to skip any `webhook-id` they have already handled.
+
+
+
+
+
+
+When the retries run out, the workflow disables the endpoint instead of dropping the event:
+
+
+
+- On success, the event is cleared from `pending` and the loop breaks, so the workflow takes the next event off the queue.
+- On failure, the endpoint is marked `disabled` and the workflow waits on the `enable` queue. The failed event and every event behind it are kept until then, so nothing is lost.
+- While it waits, the Rivet Actor can sleep. No process is held for a disabled endpoint, however long it stays off.
+- After `enable`, the loop runs again and retries the same event with a fresh set of retries.
+
+
+
+
+
+Your backend creates endpoints, emits events, and gives your customer portal two routes:
+
+
+
+
+
+
+- `emit` sends the event to the `endpoint` workflow of every endpoint subscribed to its type. `send` returns once the event is stored in the outbox, so your app never waits on a customer's server.
+- `POST /endpoints` creates the workflow with the URL and a new secret, and returns the secret once so the customer can check signatures.
+- `listAttempts` returns the delivery log, newest first, along with whether the endpoint is disabled. It never returns the secret.
+- `enable` queues a message for the waiting workflow, and only when the endpoint is disabled.
+- The `subscriptions` map is in memory to keep the example short. Store subscriptions in your database.
+
+
+These routes trust the endpoint id in the URL. In production, check that the signed-in customer owns the endpoint before you call `listAttempts` or `enable`.
+
+
+
+
+
+
+Your customers run the other half. This is what their check looks like, and the test receiver in the next step uses it:
+
+
+
+- It rebuilds the signature from the raw body and the `webhook-id` and `webhook-timestamp` headers, then compares with `timingSafeEqual`.
+- Requests signed more than 5 minutes ago are rejected, so a captured request cannot be replayed later.
+- The header can hold several signatures separated by spaces, which lets you rotate an endpoint's secret without breaking deliveries.
+
+
+
+
+
+Start the server:
+
+```sh
+npx tsx server.ts
+```
+
+In another terminal, run the client. It adds an endpoint, starts a receiver that answers `503` to the first 3 requests, and emits two events:
+
+
+
+
+
+
+```sh
+npx tsx client.ts
+```
+
+The first event fails 3 times, 2, 4, and 8 seconds apart, then gets through. The second event waits behind it and is delivered right after:
+
+```text
+endpoint ep_83460a4b-7b27-416c-acf1-856402c9b7c2
+receiver: msg_e202e0a2-9126-4a90-8cb8-189d9f9b6d0e order.created -> 503
+08:59:47 msg_e202e0a2 attempt 1: 503 (17ms)
+receiver: msg_e202e0a2-9126-4a90-8cb8-189d9f9b6d0e order.created -> 503
+08:59:49 msg_e202e0a2 attempt 2: 503 (3ms)
+receiver: msg_e202e0a2-9126-4a90-8cb8-189d9f9b6d0e order.created -> 503
+08:59:53 msg_e202e0a2 attempt 3: 503 (3ms)
+receiver: msg_e202e0a2-9126-4a90-8cb8-189d9f9b6d0e order.created -> 200
+receiver: msg_1e4e1568-28a1-498c-9d9f-bb95c83215e9 order.created -> 200
+09:00:01 msg_e202e0a2 attempt 4: 200 (3ms)
+09:00:01 msg_1e4e1568 attempt 1: 200 (2ms)
+both events delivered
+```
+
+Run it with `FAIL_FIRST=100 npx tsx client.ts` to watch the endpoint run out of retries after 6 attempts and get disabled. The client then fixes the receiver and calls `enable`, and both events are delivered in order.
+
+
+
+
+
+## Next steps
+
+- [Handle Incoming Webhooks](/guides/handle-webhooks): the receiving side, with signature checks and deduplication.
+- [Failure & Recovery](/workflows/docs/failure-and-recovery#error-hooks): alert your team or email the customer when an endpoint is disabled.
+- [Patterns](/workflows/docs/patterns#store-workflow-progress-in-state-broadcast): broadcast each attempt so the portal updates live.
+
+## Related docs
+
+- [Steps](/workflows/docs/steps#handling-terminal-failures-as-data): how `tryStep` returns failures as data.
+- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#timeouts): step timeouts, retries, and backoff.
+- [Queues & Messaging](/workflows/docs/queues#signal-driven-control-loop): waiting on a queue for a control message, like `enable`.
+- [Reference](/workflows/docs/reference#recommendations): recommendations for replay-safe workflows.
diff --git a/docs/sidebar.json b/docs/sidebar.json
index 79d2010..faca630 100644
--- a/docs/sidebar.json
+++ b/docs/sidebar.json
@@ -53,5 +53,28 @@
}
]
}
+ ],
+ "guides": [
+ {
+ "title": "Workflows",
+ "pages": [
+ {
+ "title": "Multi-Step AI Workflows",
+ "href": "/guides/ai-workflows"
+ },
+ {
+ "title": "Human-in-the-Loop Approvals",
+ "href": "/guides/human-in-the-loop"
+ },
+ {
+ "title": "Handle Incoming Webhooks",
+ "href": "/guides/handle-webhooks"
+ },
+ {
+ "title": "Reliable Webhook Delivery",
+ "href": "/guides/webhook-delivery"
+ }
+ ]
+ }
]
}
diff --git a/examples/docs/guides/ai-workflows/ai-job.ts b/examples/docs/guides/ai-workflows/ai-job.ts
new file mode 100644
index 0000000..ac214bb
--- /dev/null
+++ b/examples/docs/guides/ai-workflows/ai-job.ts
@@ -0,0 +1,155 @@
+import {
+ event,
+ queue,
+ type WorkflowStepContextOf,
+ workflow,
+} from "@rivet-dev/workflows";
+import { callModel } from "./model";
+import { type Company, lookupCompany, publishPost } from "./tools";
+
+export type JobInput = { title: string; transcript: string };
+type Extracted = { companies: string[]; actionItems: string[] };
+type Stage =
+ | "queued"
+ | "extracting"
+ | "enriching"
+ | "summarizing"
+ | "publishing"
+ | "published"
+ | "failed";
+export type Progress = { stage: Stage; detail: string | null };
+
+// docs:start retries
+// Model calls get a long timeout and slow backoff, because rate limits clear in seconds to minutes.
+const MODEL_STEP = {
+ timeout: 120_000,
+ retryOnTimeout: true,
+ maxRetries: 6,
+ retryBackoffBase: 2_000,
+ retryBackoffMax: 60_000,
+};
+// docs:end retries
+
+// One workflow per job, keyed by the job id.
+export const aiJob = workflow(
+ {
+ state: {
+ stage: "queued" as Stage,
+ extracted: null as Extracted | null,
+ companies: [] as Company[],
+ summary: null as string | null,
+ url: null as string | null,
+ },
+ queues: {
+ start: queue(),
+ },
+ events: {
+ progress: event