Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions docs/content/guides/ai-workflows.mdx
Original file line number Diff line number Diff line change
@@ -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";

<ExampleLinkBar href="https://github.com/rivet-dev/workflows/tree/main/examples/docs/guides/ai-workflows" />

<Diagram
id="ai-workflows"
label="Your app posts a job to the /jobs route of your HTTP server, which calls send on a new aiJob workflow and returns the job id right away. The workflow runs one recorded step per call: an extract step that asks the model for the companies and action items in a transcript, one enrich step per company that looks it up in your CRM, a summarize step that asks the model for a brief, and a publish step that posts the brief to your CMS. A failed step retries with backoff, and after a crash the workflow resumes at the step that failed. The workflow broadcasts progress events back to your app. The numbers show the order of the calls. There is one workflow per job."
stackLabel="One per job"
nodes={[
{ id: "app", x: 20, y: 120, w: 150, title: "Your app", subtitle: "submits a job" },
{ id: "server", kind: "group", x: 330, y: 98, w: 170, h: 84, title: "HTTP server" },
{ id: "jobs", kind: "slot", x: 345, y: 132, w: 140, h: 36, title: "`/jobs`" },
{ id: "job", x: 600, y: 120, product: "workflows", stack: true, title: "`aiJob`", subtitle: "one step per call" },
{ id: "llm", x: 920, y: 30, title: "LLM provider", subtitle: "OpenAI or compatible" },
{ id: "crm", x: 920, y: 120, title: "Your CRM", subtitle: "company lookups" },
{ id: "cms", x: 920, y: 210, title: "CMS", subtitle: "publishes the brief" },
]}
edges={[
{ from: "app", to: "jobs", label: "`POST /jobs`", step: 1 },
{ from: "jobs", to: "job", label: "`send`", step: 2 },
{ from: "job", to: "llm", fromSide: "top", fromOffset: -20, toOffset: -10, via: [[665, 50]], label: "`extract` step", step: 3 },
{ from: "job", to: "crm", label: "`enrich` steps", step: 4 },
{ from: "job", to: "llm", fromSide: "top", fromOffset: 20, toOffset: 15, via: [[705, 75]], label: "`summarize` step", step: 5 },
{ from: "job", to: "cms", fromSide: "bottom", fromOffset: 30, via: [[715, 240]], label: "`publish` step", step: 6 },
{ from: "job", to: "app", kind: "reply", fromSide: "bottom", toSide: "bottom", fromOffset: -30, via: [[655, 310], [95, 310]], label: "`progress` events", step: 7 },
]}
notes={[{ x: 730, y: 285, text: "Failed steps retry with backoff." }]}
/>

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.

<Steps>

<Step title="Install">

```sh
npm add @rivet-dev/workflows rivetkit hono @hono/node-server
```

</Step>

<Step title="Configure">

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.

</Step>

<Step title="Call the model">

Every model call goes through one function:

<CodeSnippet file="examples/docs/guides/ai-workflows/model.ts" title="model.ts" />

- 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.

</Step>

<Step title="Create the workflow">

Each job is its own `aiJob` workflow, keyed by the job id. It waits for a `start` message, then runs the extract step:

<CodeSnippet file="examples/docs/guides/ai-workflows/ai-job.ts" title="ai-job.ts" highlight="retries,extract" />

- `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.

</Step>

<Step title="Call your APIs">

Between the two model calls, the workflow looks up each company in your CRM:

<CodeGroup>
<CodeSnippet file="examples/docs/guides/ai-workflows/ai-job.ts" title="ai-job.ts" region="enrich" />
<CodeSnippet file="examples/docs/guides/ai-workflows/tools.ts" title="tools.ts" />
</CodeGroup>

- 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.

</Step>

<Step title="Summarize with the model">

The summarize step sends the transcript, the action items, and the CRM data back to the model:

<CodeSnippet file="examples/docs/guides/ai-workflows/ai-job.ts" title="ai-job.ts" region="summarize" />

- 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.

</Step>

<Step title="Publish the result">

The last step posts the brief to your CMS:

<CodeSnippet file="examples/docs/guides/ai-workflows/ai-job.ts" title="ai-job.ts" region="publish" />

- 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.

</Step>

<Step title="Report progress">

Every step calls `setStage`, and the `onError` hook reports retries:

<CodeGroup>
<CodeSnippet file="examples/docs/guides/ai-workflows/ai-job.ts" title="setStage" region="progress" />
<CodeSnippet file="examples/docs/guides/ai-workflows/ai-job.ts" title="onError" region="on-error" />
</CodeGroup>

- 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.

</Step>

<Step title="Start jobs over HTTP">

The HTTP server has one route. It creates a workflow for the job and hands it the input:

<CodeSnippet file="examples/docs/guides/ai-workflows/server.ts" title="server.ts" highlight="jobs" />

- `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.

</Step>

<Step title="Try it">

Start the server:

```sh
npx tsx server.ts
```

Then submit a job and follow its progress:

<CodeSnippet file="examples/docs/guides/ai-workflows/client.ts" title="client.ts" />

```sh
npx tsx client.ts
```

The client prints each stage as it happens, including any retries, and then the job's final state.

<Note>
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.
</Note>

</Step>

</Steps>

## 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.
180 changes: 180 additions & 0 deletions docs/content/guides/handle-webhooks.mdx
Original file line number Diff line number Diff line change
@@ -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";

<ExampleLinkBar href="https://github.com/rivet-dev/workflows/tree/main/examples/docs/guides/handle-webhooks" />

<Diagram
id="handle-webhooks"
label="Stripe, GitHub, or Shopify posts each event to the /webhooks route of your HTTP server. The route verifies the signature and calls send on the workflow keyed by the customer or object the event is about, then answers 200 right away. The workflow records each event id and skips ids it has already seen, then handles events one at a time in the order they arrived, writing each one to your database in the apply-event step. The numbers show the order of the calls. There is one workflow per customer."
stackLabel="One per customer"
nodes={[
{ id: "provider", x: 20, y: 62, w: 150, title: "Provider", subtitle: "Stripe, GitHub, Shopify" },
{ id: "server", kind: "group", x: 300, y: 40, w: 170, h: 84, title: "HTTP server" },
{ id: "webhooks", kind: "slot", x: 315, y: 74, w: 140, h: 36, title: "`/webhooks`" },
{ id: "customer", x: 600, y: 62, product: "workflows", stack: true, title: "`customerEvents`", subtitle: "event ids, in order" },
{ id: "db", x: 960, y: 62, title: "Your database", subtitle: "orders, access, billing" },
]}
edges={[
{ from: "provider", to: "webhooks", fromOffset: -8, toOffset: -8, label: "signed event", labelAt: [232, 80], step: 1 },
{ from: "webhooks", to: "customer", label: "`send` event", step: 2 },
{ from: "webhooks", to: "provider", kind: "reply", fromOffset: 8, toOffset: 8, label: "`200 OK`", labelAt: [232, 118], step: 3 },
{ from: "customer", to: "db", label: "`apply-event` step", step: 4 },
]}
/>

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.

<Steps>

<Step title="Install">

```sh
npm add @rivet-dev/workflows rivetkit hono @hono/node-server
```

</Step>

<Step title="Get a signing secret">

Stripe signs every webhook with a secret that belongs to the endpoint. In the <a href="https://dashboard.stripe.com/webhooks" target="_blank" rel="noopener noreferrer">Stripe Dashboard</a>, add an endpoint for `https://<your-server>/webhooks` and copy its signing secret. To test locally, the <a href="https://docs.stripe.com/stripe-cli" target="_blank" rel="noopener noreferrer">Stripe CLI</a> prints one when you run `stripe listen`. Then set:

```sh
STRIPE_WEBHOOK_SECRET=whsec_...
```

</Step>

<Step title="Verify the signature">

Anyone can post to a public URL, so the route checks that each request came from Stripe before it does anything else:

<CodeSnippet file="examples/docs/guides/handle-webhooks/stripe.ts" title="stripe.ts" highlight="verify" />

- Stripe sends a `Stripe-Signature` header with a timestamp and an HMAC-SHA256 of `<timestamp>.<raw body>`. 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.

<Note>
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.
</Note>

</Step>

<Step title="Skip events you have already seen">

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:

<CodeSnippet file="examples/docs/guides/handle-webhooks/customer-events.ts" title="customer-events.ts" highlight="dedupe" />

- 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.

</Step>

<Step title="Apply events in order">

Once an event is known to be new, the workflow writes it to your database:

<CodeGroup>
<CodeSnippet file="examples/docs/guides/handle-webhooks/customer-events.ts" title="customer-events.ts" highlight="apply" />
<CodeSnippet file="examples/docs/guides/handle-webhooks/db.ts" title="db.ts" highlight="db" />
</CodeGroup>

- 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.

<Warning>
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.
</Warning>

<Note>
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.
</Note>

</Step>

<Step title="Receive webhooks">

The HTTP server has one route. It verifies the request, hands the event to the right customer's workflow, and answers:

<CodeSnippet file="examples/docs/guides/handle-webhooks/server.ts" title="server.ts" highlight="webhooks" />

- 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.

</Step>

<Step title="Try it">

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:

<CodeSnippet file="examples/docs/guides/handle-webhooks/send-test-event.ts" title="send-test-event.ts" />

```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
```

<Note>
To receive webhooks from a provider before you deploy, run `ngrok http 3000` and use the ngrok URL as `https://<your-server>`.
</Note>

</Step>

</Steps>

## 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.
Loading
Loading